评论里有人说「燕麦拿铁很棒」,有人说「燕麦奶酸了」,Maya 想看出哪些词把意思拉向两边。先分词,再变向量。余弦相似度到底在比什么?
AI 系统不会像人一样直接阅读句子。它先把文本切成词元,再把词元表示成数字。本课构造一个小型词袋向量,并用余弦相似度比较文档,让背后的算术过程看得见。 你还会用字符 n-gram 处理拼写错误,并用 IDF 降低常见词的权重。
先规范化,再分词
转成小写可以减少无意差异,正则表达式可以提取类似单词的词元。真实分词器可能按标点、子词或 Unicode 采用不同规则。重要的习惯是把分词规则写清楚,并在所有地方复用。
import re
def tokenize(text):
return re.findall(r"[a-z']+", text.lower())
print(tokenize("Python makes text small."))清楚、可重复的规则
构造共享词表
向量需要固定顺序的维度。收集训练文档中的唯一词元,排序以保证确定性,再把每个词元映射到索引。词表之外的词元通常被忽略,或映射到未知词元槽位。
docs = ["python text", "text models"]
vocab = sorted({word for doc in docs for word in doc.split()})
index = {word: i for i, word in enumerate(vocab)}
print(vocab)
print(index["text"])先有词表,再有索引
把词元计数成向量
词袋向量记录每个词表词元出现的次数,有意忽略词序。它速度快、容易检查,但也意味着 “dog bites man” 和 “man bites dog” 会得到相同向量。
def vector(text, index):
counts = [0] * len(index)
for word in text.split():
if word in index:
counts[index[word]] += 1
return counts
index = {"python": 0, "text": 1}
print(vector("python text text", index))计数,而不是语法
用余弦相似度比较
点积奖励共同出现的词元,向量长度则避免长文档占据优势。余弦相似度等于点积除以两个长度。除法前要检查零向量,也要记住词汇重叠不等于真正理解含义。
import math
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(y * y for y in b))
if not norm_a or not norm_b:
return 0.0
return dot / (norm_a * norm_b)
print(round(cosine([1, 1], [1, 0]), 3))
print(round(cosine([1, 0], [0, 1]), 3))共同词越多,分数越高
字符 n-gram 能容忍拼写错误和未登录词
按词切分的词表会把拼错的单词当成未知词元。字符 n-gram 保留重叠片段,因此查询仍可能与已知词近似匹配。代价是特征空间更大、误匹配更多;当拼写和词形变化重要时,可以使用 n-gram。
def trigrams(text):
padded = " " + text.lower().replace(" ", "_") + " "
return {padded[i:i + 3] for i in range(len(padded) - 2)}
query = trigrams("lattee")
known = trigrams("latte")
print(sorted(query & known))
print(round(len(query & known) / len(query | known), 2))用共享片段代替精确词元
用 IDF 降低常见词的权重
原始计数会让高频词占据主导。逆文档频率让出现在许多文档中的词权重更低。平滑后的 IDF 可避免除以零,加权后的向量仍可用余弦相似度比较。
import math
docs = ["python text models", "python models", "text models"]
vocab = sorted({word for doc in docs for word in doc.split()})
df = {word: sum(word in doc.split() for doc in docs) for word in vocab}
idf = {word: math.log((1 + len(docs)) / (1 + df[word])) + 1 for word in vocab}
def vector(doc):
words = doc.split()
return [words.count(word) * idf[word] for word in vocab]
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
print({word: round(idf[word], 2) for word in vocab})
print([round(x, 2) for x in vector("python text")])
print(round(cosine(vector("python text"), vector("text models")), 2))稀有词携带更多信号
本课要点
- 分词是明确的设计选择,不是按空格切开那么简单。
- 词表为每个词元提供稳定的向量维度。
- 词袋向量只统计词元,不考虑词序。
- 余弦相似度比较方向,并能处理不同长度的文档。
- 词汇相似不等于语义理解。
- 字符 n-gram 能处理近似匹配;IDF 会降低常见词的拉力。
常见错误
- 每篇文档单独构造词表,导致维度无法对齐。
- 不检查零向量长度就直接相除。
- 把词袋分数当成完整的语义度量。
- 以为词级分词能处理所有拼写错误,结果把查询当作未知词悄悄丢掉。
把两个短句分词,构造一个共享词表,转成计数向量,并打印保留三位小数的余弦相似度。
动手试试
改改变量、先预测输出,再运行代码。第一次点击“运行代码”时会下载 Python 运行时,可能需要几秒钟。
练习本课
12 道题,作答后立即看到解释。