5.1 现场十三:分词与词频向量现场


5.1 现场十三:分词与词频向量现场

本节摘要:NLP 专场的开胃题:手写正向最大匹配分词器,再把分好的词变成 TF-IDF 向量、用余弦相似度比较文档。题目小,追问深——词典歧义、未登录词、对数压缩、复杂度陷阱,层层都有话说。它把第 1 章现场一的英文词频统计接到中文世界,也为现场十四的向量表示铺路。

「词袋」这个词在 NLP 面试里出现得毫无新意,但把它真正落到代码上,新意立刻就来了:英文有空格,正则一扫就切干净——那是现场一的做法;中文没有空格,切分本身成了第一道考点。文本组面试官用这道题开场,看的就是你在"最朴素的表示"上有没有扎实的字符串功力和统计直觉。

考题现场

"给你一个小词典,写一个中文分词函数,用正向最大匹配:从句首开始,每次切出词典里最长的词。然后把手里的两句话变成 TF-IDF 向量,算它们的余弦相似度。最后回答:南京市长江大桥这句话,你的分词器切对吗?为什么 IDF 要取对数?"

问句里的埋点很密:最大匹配是算法,TF-IDF 是统计,歧义句是边界,对数是动机。逐层都有东西可答,才配叫开胃题。

现场推演

候选人先口述最大匹配的规则:拿一个指针从句首出发,尝试从最大词长往下递减,检查每个候选切片在不在词典里;命中最长的那个就切下来,指针前移;全都命不中就把当前字当单字切出去。词典 he 顺手用集合存——查找是常数级。落笔:

DIC = {'南京': '', '南京市': '', '市长': '', '长江': '', '大桥': '', '江大桥': '', '自然': '', '自然语言': '', '语言': '', '处理': '', '确实': '', '有': '', '深度': ''} def fmm(sent, max_len=4): out, i = [], 0 while i < len(sent): for L in range(min(max_len, len(sent) - i), 0, -1): if sent[i:i + L] in DIC: # 词典用 set,查找 O(1) out.append(sent[i:i + L]) i += L break else: # 一个词长都命不中,切单字 out.append(sent[i]) i += 1 return out print(fmm('自然语言处理确实有深度')) print(fmm('南京市长江大桥'))
['自然语言', '处理', '确实', '有', '深度'] ['南京市', '长江', '大桥']

第二个输出正是面试官埋的那道歧义题:正确的读法"南京市 / 长江 / 大桥"被切出来了。但候选人主动补刀:"这个例子其实说明不了分词器聪明——把词典里'南京市'删掉,同样这句话会切成'南京 / 市长 / 江大桥',语义全错。最大匹配只认词典不认语义,歧义靠词典覆盖度碰运气,工程上要靠统计模型或双向匹配来消歧。"面试官在这段话上点了头——知道工具的边界,比会用工具值钱。

分完词,向量化的部分反而轻车熟路:词频除以文档长度做归一化,文档频率取对数压权重,两者相乘就是 TF-IDF。

import numpy as np docs = ['深度 学习 改变 视觉', '深度 学习 改变 语言', '语言 学习 从 文字 开始'] def tfidf_matrix(docs): vocab = sorted({w for d in docs for w in d.split()}) N, V = len(docs), len(vocab) tf = np.zeros((N, V)) for i, d in enumerate(docs): for w in d.split(): tf[i, vocab.index(w)] += 1 # 演示用;词表大时要换哈希 tf[i] /= len(d.split()) # 词频按文档长度归一化 df = (tf > 0).sum(axis=0) # 每个词出现在几篇文档 idf = np.log(N / df) + 1.0 # 取对数压缩 + 平滑 return vocab, tf * idf vocab, M = tfidf_matrix(docs) def cosine(u, v): return float(u @ v / (np.linalg.norm(u) * np.linalg.norm(v))) print('词表:', vocab) print('前两篇相似度:', round(cosine(M[0], M[1]), 4)) print('一三篇相似度:', round(cosine(M[0], M[2]), 4))
词表: ['从', '学习', '开始', '改变', '文字', '深度', '视觉', '语言'] 前两篇相似度: 0.615 一三篇相似度: 0.0813

数字会说话:前两篇共享"深度、学习、改变",相似度零点六一五;第一与第三篇只共享"学习",跌到零点零八一。词袋模型不懂语义,但共现词的权重差足够把"像与不像"拉开——这就是它活了半个世纪的原因。

追问链

追问一:IDF 为什么要取对数? 候选人答:"直接用文档总数除以文档频率,稀有词的权重会爆炸——出现在全部文档里的词权重是一,只出现在一篇里的词权重可能是几万,乘上词频后稀有词独霸整个向量。对数把这种悬殊压成可相加的尺度:出现在的文档少一半,权重只加一个固定常数,排序不变、幅度可控。"他顺手补了平滑项的存在理由:万一某词出现在所有文档,N 除以 N 得一,取对数是零,加一保证它还有底权而不是被当成无意义停用词一刀切。

追问二:句子里有个词不在词典里怎么办? "这就是未登录词问题。字典式分词切不出它,最大匹配会把它剁成单字。缓解的路子:一,加人名地名数字这类规则识别器;二,上统计分词或序列标注模型,让概率而不是词典做决定;三,词表示层面用子词——把词拆成更小的稳定单元,罕见词也能拼出来。现代做法基本都滑向了子词。"面试官追问子词的直觉,他答:"英文里吃、eating、eater 共享词根,子词把这种共享显式变成词表条目,词表规模可控,未登录词趋近于零。"

追问三:这份 TF-IDF 代码,复杂度哪里有坑? 这问的是 vocab.index(w)。候选人自己招了:"这是刚才为了可读留下的坑——list 的 index 是线性扫描,整个构建是文档数乘词数乘词表规模的平方级。词表一上十万,立刻卡死。改法是把词表变成 dict:词到下标一次映射,整体掉回线性。生产里没人手写这个,sklearn 的 CountVectorizer 内部就是哈希映射加稀疏矩阵。"主动认领自己代码的复杂度坑,比被面试官戳穿体面得多——这是他复用上一场教训的动作。

优化与变式

分词的两个直接变式:逆向最大匹配(从句尾往前切),以及双向最大匹配(两个方向都切,词数不一致就报歧义,交给更大粒度的规则裁决)。向量化的变式按信息量递进:二值特征(出现记一,短文本更稳,下一场会用到)、词频、TF-IDF,再往后是静态词向量与上下文相关的预训练表示——每一代都在解决上一代的缺陷,词袋缺序语义的缺陷则要等现场十四的注意力来补。变式还可以换个任务壳:搜索里的查询与文档匹配、推荐里的用户画像与物品标签,底座都是这套"切分、计数、加权、比余弦"。

失误复盘

高频翻车点:切片时指针忘了前移,死循环;for...else 的 else 语义没吃透,命不命中都切单字;TF 忘了按文档长度归一化,长文档相似度系统性偏高;余弦里忘了除以模长,退化成内积;IDF 忘了平滑,全文档词直接变负无穷。还有一类是本节真实上演的复杂度坑——为了可读写出平方级代码。白板现场的防御动作:写向量化的任何东西之前,先口头声明"词到下标用哈希",把这个习惯焊死。

主线候选人这一场答得干净,唯一被追着打的是歧义句——他先答对了切分,却被"为什么对"问住了,诚实回答"词典刚好覆盖,不代表分词器有语言理解"。面试官的评语:"知道自己为什么对,比答对本身高一个段位。"

关键直觉:词袋的三件套——切分、计数、加权——没有一件难写,但每一件都埋着一个边界(歧义、未登录、复杂度);面试官开这道题,挖的就是你对边界的嗅觉。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U