Skip to content

s14 文本表示 — exercise.py 练习指南

Download exercise.py

练习目标

通过手写 TF-IDF 和 word2vec 的核心算法,深入理解文本表示的两种范式:

  1. 实现 IDF 计算 —— 理解逆文档频率如何量化词的区分能力
  2. 实现负采样损失 —— 理解 Skip-gram 训练的核心损失函数
  3. 实现余弦相似度 —— 向量相似度的标准度量

预备知识

  • TF-IDFTF-IDF(w,d)=TF(w,d)×IDF(w)
    • TF(w,d)=c(w,d)wc(w,d)
    • IDF(w)=logNdf(w)
  • Skip-gram:给定中心词 wt,预测上下文词 wt+j
  • 负采样损失L=logσ(vwtuwc)i=1Klogσ(vwtuwi)
  • 余弦相似度cos(a,b)=abab

任务清单

练习 1:实现 TF-IDF 的 IDF 计算

任务:在 compute_idf(tokenized_docs) 中实现 IDF 计算。

公式

IDF(w)=logNdf(w)

其中:

  • N:总文档数
  • df(w):包含词 w 的文档数(Document Frequency,不是词频)

步骤提示

1. 统计每个词出现在多少篇文档中(每篇文档内部去重)
2. 对每个词,IDF = log(N / df(w))
3. 可选平滑:IDF = log((N+1) / (df(w)+1)) + 1

代码框架

python
def compute_idf(tokenized_docs):
    N = len(tokenized_docs)
    df = {}
    for doc in tokenized_docs:
        for word in set(doc):  # 去重:每篇文档每个词只计一次
            df[word] = df.get(word, 0) + 1

    idf = {}
    for word, doc_freq in df.items():
        idf[word] = math.log((N + 1) / (doc_freq + 1)) + 1  # 平滑版
    return idf

关键细节:set(doc) 的去重作用

考虑词"学习"出现在 3 篇文档中:

  • 在文档 1 中出现 5 次
  • 在文档 3 中出现 2 次
  • 在文档 5 中出现 1 次

df("学习") = 3(它出现在 3 篇文档中),不是 5+2+1=8。IDF 关心的是文档频率(多少篇文档),不是词频(出现了多少次)。

测试用例

python
test_docs = [
    ["机器", "学习", "是", "人工智能", "的", "分支"],
    ["深度", "学习", "需要", "大量", "数据"],
    ["机器", "学习", "模型", "需要", "训练"],
]
# 预期:"的"的IDF最高(只出现在1篇文档中)
#        "学习"的IDF最低(出现在3篇文档中)

练习 2:实现负采样损失函数

任务:在 negative_sampling_loss(v_center, u_pos, u_neg) 中实现 Skip-gram 负采样损失。

公式

L=logσ(vcenterupos)i=1Klogσ(vcenteruneg,i)

其中:

  • vcenter:中心词的输入向量
  • upos:正样本上下文词的输出向量
  • uneg,i:第 i 个负样本词的输出向量
  • σ:sigmoid 函数 σ(x)=11+ex

为什么用 F.logsigmoid 而不是 F.log(F.sigmoid(...)) log_sigmoid 在数值上更稳定。当 x 很小时,σ(x)0logσ(x),直接计算会导致 NAN。logsigmoid 在实现中用 log(1+ex)1=log(1+ex) 避免了数值溢出。

步骤提示

1. 计算正样本得分: pos_score = sum(v_center * u_pos, dim=1)  → (batch,)
2. 正样本损失: pos_loss = F.logsigmoid(pos_score).mean()     → 标量
3. 计算负样本得分: neg_score = (u_neg @ v_center.unsqueeze(-1)).squeeze(-1)  → (batch, K)
4. 负样本损失: neg_loss = F.logsigmoid(-neg_score).sum(dim=1).mean()  → 标量
5. 总损失: loss = -(pos_loss + neg_loss)

代码框架

python
def negative_sampling_loss(v_center, u_pos, u_neg):
    # 正样本:希望 v·u_pos 很大 → σ(v·u_pos) ≈ 1 → log loss 小
    pos_score = torch.sum(v_center * u_pos, dim=1)        # (batch,)
    pos_loss = F.logsigmoid(pos_score).mean()              # mean (batch,)

    # 负样本:希望 v·u_neg 很小 → σ(-v·u_neg) ≈ 1 → log loss 小
    neg_score = torch.bmm(u_neg, v_center.unsqueeze(2)).squeeze(2)  # (batch, K)
    neg_loss = F.logsigmoid(-neg_score).sum(dim=1).mean()           # sum over K, mean over batch

    return -(pos_loss + neg_loss)

损失函数的直觉

  • 好的中心词向量 v 应该与正样本上下文词向量 u+ 的内积很大σ1logσ0
  • 同时与负样本词向量 u 的内积很小σ(x)1logσ(x)0

这本质上是在训练向量空间中:拉近与相关词的距离,推远与不相关词的距离。

torch.bmm 维度说明

  • u_neg 形状:(batch, K, d)
  • v_center.unsqueeze(2) 形状:(batch, d, 1)
  • torch.bmm(u_neg, v_center.unsqueeze(2)) 做了 (batch, K, d) × (batch, d, 1) = (batch, K, 1)
  • .squeeze(2) 去掉最后一维:(batch, K)

这计算了每个 batch 样本中,K 个负样本与中心词的批量内积。

练习 3:实现余弦相似度

任务:在 cosine_similarity(v1, v2) 中实现余弦相似度计算。

公式

cos(a,b)=abab=iaibiiai2ibi2

步骤提示

1. dot = np.dot(v1, v2)                     # 分子: 点积
2. norm = np.linalg.norm(v1) * np.linalg.norm(v2)  # 分母: 范数之积
3. 如果 norm == 0: 返回 0.0                    # 防止除零
4. 返回 dot / norm

代码框架

python
def cosine_similarity(v1, v2):
    dot = np.dot(v1, v2)
    norm = np.linalg.norm(v1) * np.linalg.norm(v2)
    if norm == 0:
        return 0.0
    return dot / norm

测试用例

v1v2预期 cos直观含义
[1,2,3][1,2,3]1.0完全相同
[1,2,3][-1,-2,-3]-1.0完全相反
[1,2,3][1,0,0] 0.267部分重叠

余弦相似度的几何理解

  • cos = 1:两个向量指向完全相同方向(但长度可能不同)
  • cos = 0:两个向量正交(互不相关)
  • cos = -1:两个向量指向完全相反方向

对于词向量,v足球v篮球 的余弦相似度通常在 0.6-0.9,而 v足球v医学 的余弦相似度接近 0。

为什么余弦相似度在 NLP 中比欧氏距离更好? 词向量的长度与词频相关——高频词的向量更长。如果用欧氏距离,高频词的空间位置影响了所有距离计算。余弦相似度只关心方向,消除了频率偏差,更准确地反映了语义相似性。

完整代码

py
# -*- coding: utf-8 -*-
"""
s14 文本表示 — 练习题
==============================================
请补全以下 TODO 部分,完成后运行验证。
"""

import numpy as np
import math
import torch
import torch.nn.functional as F

# ============================================================
# 练习 1:实现 TF-IDF 的 IDF 计算
# ============================================================

def compute_idf(tokenized_docs: list) -> dict:
    """
    TODO: 实现 IDF 计算
    IDF(w) = log(N / df(w))
    其中 N 是总文档数,df(w) 是包含词 w 的文档数

    参数:
        tokenized_docs: 文档列表,每个文档是词的列表
    返回:
        dict: {词: IDF值}
    """
    N = len(tokenized_docs)
    df = {}
    for doc in tokenized_docs:
        for word in set(doc):  # 每篇文档中每个词只计一次
            df[word] = df.get(word, 0) + 1

    # TODO: 计算 IDF
    # 公式: idf[word] = log(N / df[word])
    # 使用 math.log 计算自然对数,加平滑避免除零
    idf = {}
    # ===== 你的代码在这里 =====
    # 提示:
    #   for word, doc_freq in df.items():
    #       idf[word] = math.log(N / doc_freq)
    #       # 可选:使用平滑 (N+1)/(doc_freq+1) + 1
    # ==========================
    return idf


# 测试数据
test_docs = [
    ["机器", "学习", "是", "人工智能", "的", "分支"],
    ["深度", "学习", "需要", "大量", "数据"],
    ["机器", "学习", "模型", "需要", "训练"],
]
print("[练习1] 你的 IDF 计算结果:")
print(compute_idf(test_docs))
# 预期:"的"的 IDF 应该最高(只出现在1篇文档中),"学习"的 IDF 较低(出现在3篇文档中)


# ============================================================
# 练习 2:实现负采样损失函数
# ============================================================

def negative_sampling_loss(
    v_center: torch.Tensor,  # (batch, embed_dim) — 中心词向量
    u_pos: torch.Tensor,     # (batch, embed_dim) — 正样本上下文向量
    u_neg: torch.Tensor,     # (batch, num_neg, embed_dim) — 负样本向量
) -> torch.Tensor:
    """
    TODO: 实现 Skip-gram 负采样损失函数

    损失公式:
    L = -[log σ(v_center · u_pos)] - Σ[log σ(-v_center · u_neg_i)]
    其中 σ 是 sigmoid 函数,· 是向量点积

    参数:
        v_center: 中心词向量 (batch, d)
        u_pos: 正样本上下文向量 (batch, d)
        u_neg: 负样本向量 (batch, K, d)
    返回:
        loss: 标量损失值
    """
    # TODO: 实现损失计算
    # 步骤:
    #   1. 计算正样本得分 pos_score = sum(v_center * u_pos, dim=1) → (batch,)
    #   2. 用 F.logsigmoid(pos_score) 计算正样本 log 概率
    #   3. 计算负样本得分 neg_score = batch_mm(u_neg, v_center) → (batch, K)
    #   4. 用 F.logsigmoid(-neg_score) 计算负样本 log 概率
    #   5. 总损失 = -(pos_loss.mean() + neg_loss.sum(dim=1).mean())
    # ===== 你的代码在这里 =====
    loss = torch.tensor(0.0)
    # ==========================
    return loss


# 测试负采样损失
batch_size, embed_dim, num_neg = 4, 5, 3
v_test = torch.randn(batch_size, embed_dim)
u_pos_test = torch.randn(batch_size, embed_dim)
u_neg_test = torch.randn(batch_size, num_neg, embed_dim)
print(f"\n[练习2] 你的负采样损失: {negative_sampling_loss(v_test, u_pos_test, u_neg_test).item():.4f}")


# ============================================================
# 练习 3:计算余弦相似度
# ============================================================

def cosine_similarity(v1: np.ndarray, v2: np.ndarray) -> float:
    """
    TODO: 实现两个向量的余弦相似度

    cos(v1, v2) = (v1 · v2) / (||v1|| × ||v2||)

    参数:
        v1, v2: numpy 数组
    返回:
        余弦相似度,范围 [-1, 1]
    """
    # TODO: 实现
    # 步骤:
    #   1. 计算点积 dot = np.dot(v1, v2)
    #   2. 计算范数 norm = ||v1|| × ||v2||
    #   3. 返回 dot / norm(注意处理 norm=0 的情况)
    # ===== 你的代码在这里 =====
    return 0.0
    # ==========================


# 测试余弦相似度
a = np.array([1.0, 2.0, 3.0])
b = np.array([1.0, 2.0, 3.0])  # 完全相同 → sim = 1.0
c = np.array([-1.0, -2.0, -3.0])  # 完全相反 → sim = -1.0
d = np.array([1.0, 0.0, 0.0])  # 正交部分

print(f"\n[练习3] 余弦相似度测试:")
print(f"  cos(a, b) = {cosine_similarity(a, b):.4f}  (期望: 1.0000)")
print(f"  cos(a, c) = {cosine_similarity(a, c):.4f}  (期望: -1.0000)")
print(f"  cos(a, d) = {cosine_similarity(a, d):.4f}  (期望: 0.2673)")

print("\n所有练习测试完成!请对比 demo.py 查看参考实现。")