s14 文本表示 — exercise.py 练习指南
练习目标
通过手写 TF-IDF 和 word2vec 的核心算法,深入理解文本表示的两种范式:
- 实现 IDF 计算 —— 理解逆文档频率如何量化词的区分能力
- 实现负采样损失 —— 理解 Skip-gram 训练的核心损失函数
- 实现余弦相似度 —— 向量相似度的标准度量
预备知识
- TF-IDF:
- Skip-gram:给定中心词
,预测上下文词 - 负采样损失:
- 余弦相似度:
任务清单
练习 1:实现 TF-IDF 的 IDF 计算
任务:在 compute_idf(tokenized_docs) 中实现 IDF 计算。
公式:
其中:
:总文档数 :包含词 的文档数(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 篇文档中),不是
测试用例:
python
test_docs = [
["机器", "学习", "是", "人工智能", "的", "分支"],
["深度", "学习", "需要", "大量", "数据"],
["机器", "学习", "模型", "需要", "训练"],
]
# 预期:"的"的IDF最高(只出现在1篇文档中)
# "学习"的IDF最低(出现在3篇文档中)练习 2:实现负采样损失函数
任务:在 negative_sampling_loss(v_center, u_pos, u_neg) 中实现 Skip-gram 负采样损失。
公式:
其中:
:中心词的输入向量 :正样本上下文词的输出向量 :第 个负样本词的输出向量 :sigmoid 函数
为什么用 F.logsigmoid 而不是 F.log(F.sigmoid(...))? log_sigmoid 在数值上更稳定。当 logsigmoid 在实现中用
步骤提示:
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)损失函数的直觉:
- 好的中心词向量
应该与正样本上下文词向量 的内积很大( , ) - 同时与负样本词向量
的内积很小( , )
这本质上是在训练向量空间中:拉近与相关词的距离,推远与不相关词的距离。
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) 中实现余弦相似度计算。
公式:
步骤提示:
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测试用例:
| v1 | v2 | 预期 cos | 直观含义 |
|---|---|---|---|
[1,2,3] | [1,2,3] | 1.0 | 完全相同 |
[1,2,3] | [-1,-2,-3] | -1.0 | 完全相反 |
[1,2,3] | [1,0,0] | 部分重叠 |
余弦相似度的几何理解:
cos = 1:两个向量指向完全相同方向(但长度可能不同)cos = 0:两个向量正交(互不相关)cos = -1:两个向量指向完全相反方向
对于词向量,
为什么余弦相似度在 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 查看参考实现。")