ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Word2Vec中文文本相似度实战:从预处理到训练与调优

Word2Vec中文文本相似度实战:从预处理到训练与调优 简介面向中文自然语言处理初学者的实践资源包系统覆盖文本预处理、Word2Vec词向量训练与文本相似度计算全流程适合需要动手完成中文文本清洗、分词、停用词过滤以及词向量建模的入门者和相关课程设计。压缩包共3个文件含两个Python脚本和一个CSV格式语料其中脚本分别实现文本预处理与词向量训练语料为中文酒店评论数据集整体大小仅1.19MB。已有658人学习下载可作为快速上手的参考。借助该包可掌握数据导入、匹配清洗、中文分词、停用词处理等关键环节了解CBOW和Skip-gram两种架构的特点并通过调节窗口、嵌入维度等参数完成词向量训练最终基于余弦相似度计算文本语义距离。示例数据自带标签脚本结构清晰、注释友好便于直接运行复现也方便替换自定义语料扩展到情感分析、语义检索等场景。1. 用 Word2Vec 算中文文本相似度第一步不是调参而是文本预处理用 Word2Vec 算中文文本相似度第一步通常不是调参而是文本预处理。拿 ChnSentiCorp_htl_all.csv 这种酒店评论数据集来说直接读进来是一堆带标点、带数字、带特殊符号的原始文本按空格切分甚至会切出“酒店。”“真不错”这种连着标点的词更别提“的”“了”“在”这类高频停用词把向量空间搅浑。这份压缩包里的 Word2vec-with-Chinese-character-master 项目把中文文本预处理、Word2Vec 训练、文本相似度计算整条链路拆成了两个可执行脚本文本预处理.py负责数据导入、清洗、分词、停用词过滤词向量训练.py负责训练词向量并用余弦相似度衡量文本之间的语义距离。适合刚接触 NLP 的从业者想拿真实中文语料跑通一个能用的相似度模型又不满足于网上只讲概念不讲代码的教程。2. 文本预处理从 CSV 原始评论到干净的分词序列先说明这套资源的工作流文本预处理.py读入datasets/ChnSentiCorp_htl_all.csv经过编码处理、清洗、标点标准化、分词、停用词过滤最终把每条评论转成以空格分隔的词序列并写盘词向量训练.py再读这个中间文件训练 Word2Vec。两步解耦的好处是调 Word2Vec 参数时不用反复跑预处理。2.1 数据导入与编码中文 CSV 的读取姿势ChnSentiCorp_htl_all.csv 是带情感标签的中文酒店评论语料字段一般包含 label 和 review 两列label 是 0/1 情感倾向review 是评论文本。用 pandas 读取时最容易翻车的是编码问题网上不少教程直接pd.read_csv(xxx.csv)遇到 GBK 或者带 BOM 的 UTF-8 文件就会报UnicodeDecodeError。import pandas as pd df pd.read_csv( datasets/ChnSentiCorp_htl_all.csv, encodingutf-8-sig, # 兼容带 BOM 的 UTF-8Excel 导出的 CSV 常见 enginepython # 遇到编码错位时的兜底引擎 ) print(df.info()) print(df.head())逻辑说明pandas 默认enginec性能好但对不规范的中文 CSV 容错差偶尔会出现列错位或直接抛错。encodingutf-8-sig能同时兼容带 BOM 和不带 BOM 的 UTF-8 文件enginepython的行尾处理更灵活缺点是慢一些几万条评论的场景可以忽略。读进来先跑info()和head()确认 label 和 review 两列没有读歪再继续清洗。如果报 GBK 相关错误把编码换成encodinggbk再试。如果文件编码混排可以用chardet先探测import chardet with open(datasets/ChnSentiCorp_htl_all.csv, rb) as f: raw f.read(20000) print(chardet.detect(raw))这一步不是必须的但能提前排除编码这个最常见的黑匣子。实际项目里我一般会在预处理脚本开头加一段编码探测逻辑文件格式一变就能立刻定位问题。2.2 清洗规则正则表达式干掉 HTML、数字、URL 和混杂标点酒店评论里经常混着br/换行标签、网页链接、电话号码和一堆全角半角混淆的标点。这些噪声对语义相似度没有帮助而且会干扰分词——br/不拆掉jieba 会把、当成单词的一部分切出莫名其妙的碎片。import re def clean_text(text: str) - str: # 去掉 HTML 标签br/、p 之类 text re.sub(r[^], , text) # 去掉 URL text re.sub(r(https?://|www\.)[\w\-\.]\.\w[\w\-\./]*, , text) # 连续空白包含换行和 Tab统一压成单个空格 text re.sub(r\s, , text) return text逻辑说明先清标签再压空白顺序不能反。如果先执行\sbr/里的换行被提前吞掉HTML 标签的正则可能就匹配不完整了。URL 正则覆盖了http/https和www开头两类常见形式够用就好不用追求覆盖所有边缘情况。清洗完还要做标点标准化这一步很多人会漏。中文用户经常混用全角和半角逗号、括号如果不统一同一个词在不同评论里可能因为标点边界不同被切分成不同形式def normalize_punctuation(text: str) - str: # 常见全角标点映射到半角中文句末标点保留但转成半角 table { : ,, 。: ., : !, : ?, : :, : ;, “: , ”: , ‘: , ’: , : (, : ) } for k, v in table.items(): text text.replace(k, v) # 把所有剩余的非中英文、非数字符号替换成空格 text re.sub(r[^\w\u4e00-\u9fa5], , text) return text参数说明最后一条正则[^\w\u4e00-\u9fa5]匹配除了英文字母、数字、下划线和中文汉字之外的所有字符统一替换成空格。注意它会保留英文单词里的下划线如果业务上不需要下划线可以把这个字符类改成[^a-zA-Z0-9\u4e00-\u9fa5]。我一般先做显式映射再用正则兜底这样既照顾了常见标点又不会漏掉生僻符号。2.3 分词与停用词jieba 的精确模式和停用词表筛选中文没有天然空格边界分词是预处理的核心步骤。文本预处理.py里用的应该是 jieba这是中文 NLP 里最普及的分词工具安装简单、词典覆盖够用。import jieba stopwords set() with open(stopwords.txt, r, encodingutf-8) as f: for line in f: w line.strip() if w: stopwords.add(w) def tokenize(text: str) - list: text clean_text(text) text normalize_punctuation(text) words jieba.cut(text, cut_allFalse) return [w for w in words if w.strip() and w not in stopwords and len(w) 1]参数说明jieba.cut(text, cut_allFalse)是精确模式也是训练词向量时的稳妥选择。cut_allTrue是全模式会把“酒店管理”切出“酒店”“管理”“酒店管理”等多个重叠碎片喂给 Word2Vec 会引入大量噪声。过滤条件里w not in stopwords去掉“的”“了”“是”这类高频虚词len(w) 1过滤单字词。这里有一个需要按业务权衡的点单字词不一定都要过滤。像“好”“差”“吵”这种在情感分析里是强信号如果做相似度任务它们反而会稀释语义。依赖这份资源的场景是计算文本相似度所以过滤单字词问题不大但如果后续要拿同一个预处理结果做情感分析建议把len(w) 1去掉或者单独保留一份带单字词的版本。停用词表不要在网上下一个“通用版”就直接用。我踩过的坑是很多停用词表把“不”“没”也收进去了结果“不满意”被切完就剩“满意”情感倾向直接反转。正确做法是先跑一遍分词看高频词列表再人工决定哪些词进停用词表。2.4 预处理脚本落盘把分词结果输出成 gensim 能读的格式分词做完要把结果写到中间文件。gensim 的LineSentence类可以直接读取“每行一条评论词之间空格分隔”的纯文本文件这个格式也是文本预处理.py和词向量训练.py之间的标准接口。from tqdm import tqdm with open(segmented.txt, w, encodingutf-8) as out: for idx, row in tqdm(df.iterrows(), totallen(df)): tokens tokenize(row[review]) out.write( .join(tokens) \n)这里用tqdm不是为了好看是十几万条评论的分词耗时以分钟计没有进度条的话脚本看起来就像卡死了。写完之后抽查几行head -5 segmented.txt如果看到主题词还在、标点和数字没了、停用词被过滤了预处理这步就算过了。注意df.iterrows()在行数很大时性能一般如果语料超过几十万行可以考虑先把 review 列转成 list 再循环。3. Word2Vec 训练CBOW 与 Skip-gram 选型、参数设置与验证预处理产出的segmented.txt就是 Word2Vec 的输入。训练阶段要做的决策集中在模型架构选择和超参数设置这两点直接决定词向量的质量进而影响文本相似度的计算结果。3.1 CBOW 与 Skip-gram 的原理差异和适用场景Word2Vec 本质上是一个浅层神经网络目标是给每个词学出一个稠密向量让语义相近的词在向量空间里距离更近。CBOW 和 Skip-gram 是它的两种架构CBOW 用上下文词预测当前中心词Skip-gram 反过来用中心词预测上下文词。这两种架构在数据量上的表现差异很明显。CBOW 训练更快对高频词的表示更平滑适合语料规模大、高频词主导的场景Skip-gram 训练慢一点但对低频词和罕见词的学习更充分生成的向量更细粒度。ChnSentiCorp_htl_all.csv 这个酒店评论数据集本身规模不算大评论里的低频词占比不低这时候我更倾向用 Skip-gram。尤其当你用 Word2Vec 算文本相似度时低频词往往才是区分度所在——比如“性价比”“隔音”“交通”这些词出现的频率不如“酒店”“房间”高但它们对语义的贡献更大。在这份资源里词向量训练.py用的应该是 gensim 的Word2Vec实现。gensim 的接口封装得很好传入的必须是已经分好词、按空格分隔的句子序列。3.2 训练脚本与核心参数解读from gensim.models import Word2Vec from gensim.models.word2vec import LineSentence # LineSentence 逐行读取预处理结果避免一次性把全部语料加载进内存 sentences LineSentence(segmented.txt) model Word2Vec( sentences, vector_size100, # 词向量维度 window5, # 上下文窗口大小 min_count5, # 过滤出现次数少于 5 次的低频词 sg1, # 1 表示 Skip-gram0 表示 CBOW negative5, # 负采样数量 workers4, # 并行线程数 epochs10, # 迭代次数 seed42 # 固定随机种子保证结果可复现 ) model.save(w2v_model.model)参数说明vector_size100嵌入维度。几百万级语料可以设 200 到 300几万条评论的数据量设 100 足够。设太大会在小语料上过拟合学出的向量携带大量噪声设太小又表达不了复杂的语义关系。window5上下文窗口。中文评论句子普遍较短窗口太大容易引入无关词5 到 7 是比较常见的选择。做情感分析任务时我会把窗口调小到 3 到 5让模型更关注局部搭配。min_count5出现次数少于 5 的词直接丢弃。这是个关键的过滤阈值太小会让低频噪声词占据向量表太大则会让不少有区分的低频词消失。sg1选择 Skip-gram。数据量小、低频词占比高的场景选它更稳。negative5负采样数量。这个参数控制了训练时的负样本数5 是 gensim 的默认推荐值。语料干净时用默认即可。epochs10迭代次数。小数据上 5 到 20 次都有人用我一般先用 10 试跑观察 loss 曲线再决定是否增加。seed42固定随机种子。不固定的话同样的代码每次跑出的相似度结果都可能不同排查问题时会很难受。训练过程里最容易被忽略的是LineSentence这个类。它按行读取文件不会把整个语料一次性载入内存对几十万行文本很友好。Windows 上如果设置了workers1偶尔会报EOFError这时候把workers改成 1 就能绕过去后面第 5 章会细说。3.3 训练后的基础验证先别急着算相似度模型保存之后我习惯先做三个快速检查确认向量真的学到了语义而不是在垃圾输入上硬训。# 检查 1最相似词是否合理 print(model.wv.most_similar(酒店, topn10)) # 检查 2同义词和反义词的向量距离 print(model.wv.similarity(干净, 整洁)) print(model.wv.similarity(干净, 吵闹)) # 检查 3类比推理能力经典的是「国王 - 男人 女人 女王」 print(model.wv.most_similar(positive[服务, 便宜], negative[贵], topn5))most_similar返回与目标词余弦距离最近的词。如果“酒店”的最相似词是“宾馆”“饭店”“旅馆”说明模型学到了基本语义如果返回一堆不相关的词问题大概率出在预处理或训练参数上再往下算文本相似度没有意义。4. 文本相似度计算词向量平均与余弦相似度的工程实现模型训练完就可以算两个文本的相似度了。文本相似度的实现方式有不少这份资源采用的做法是基于词向量平均再加余弦相似度这也是最直观、最容易落地的一种。4.1 为什么不用词面重叠而是用词向量平均如果只想比较两段评论“长得像不像”直接用 Jaccard 相似度或者 TF-IDF 向量求余弦就够了但它们都解决不了同义词问题。“干净”和“整洁”字面上没有任何重叠人到是知道它们是一回事词面方法算出的相似度却是 0。Word2Vec 学出的分布式表示恰恰能捕捉这种语义邻近性——训练时“干净”和“整洁”经常出现在类似语境里向量方向就会趋于一致。词向量平均的做法很直接把一条评论的所有词的向量取平均得到句子的向量表示再算两条句子向量的余弦相似度。它的假设是一条句子的语义可以用其中词语语义的均值来近似。这个假设在短文本上基本成立在长文本上会损失语序信息但作为第一版相似度模型足够用了。4.2 句子向量与余弦相似度的实现import numpy as np from gensim.models import Word2Vec model Word2Vec.load(w2v_model.model) def sentence_vector(tokens, model): vectors [] for token in tokens: if token in model.wv: vectors.append(model.wv[token]) if not vectors: return np.zeros(model.vector_size) return np.mean(vectors, axis0) def cosine_similarity(vec_a, vec_b): norm_a np.linalg.norm(vec_a) norm_b np.linalg.norm(vec_b) if norm_a 0 or norm_b 0: return 0.0 return float(np.dot(vec_a, vec_b) / (norm_a * norm_b))逻辑说明sentence_vector先遍历分词结果只保留在词表里的词避免 OOV 词out of vocabulary词表外词带进 NaN。如果整条评论的词都不在词表里返回全零向量。cosine_similarity先检查两个向量是否为零向量再计算余弦值返回 0 到 1 之间的相似度分数。这段代码有个值得留意的细节np.mean(vectors, axis0)用的是所有有效词向量的算术平均没有做模长归一化。词向量的绝对值本身没有实际意义方向才有意义所以在算相似度之前可以先把每个词向量归一化成单位向量再平均。归一化后的平均向量余弦相似度的计算结果会更稳定。4.3 把相似度计算接到两个预处理后的句子上实际使用时新进来的评论也要走一遍和训练数据相同的预处理流程再喂给模型算向量。这里最常见的问题是分词不一致——训练时 jieba 把“性价比高”切成“性价比/高”预测时却切成了“性价/比高”模型就认不出来了。import jieba def predict_similarity(text_a, text_b, model, stopwords): tokens_a [w for w in jieba.cut(text_a) if w not in stopwords and len(w) 1] tokens_b [w for w in jieba.cut(text_b) if w not in stopwords and len(w) 1] vec_a sentence_vector(tokens_a, model) vec_b sentence_vector(tokens_b, model) return cosine_similarity(vec_a, vec_b) # 示例 text_1 房间很干净服务态度也好性价比高 text_2 这家酒店卫生条件不错前台接待很热情 print(predict_similarity(text_1, text_2, model, stopwords))案例中两句话没有共同名词但“干净”和“卫生”“服务”和“接待”在语义空间里位置接近所以算出的相似度会有实际区分度。如果输出接近 1 或者接近 0先别怀疑算法回头检查分词和停用词过滤是不是和训练时保持一致。5. 避坑与排查文本预处理和 Word2Vec 训练中值得记录的五个翻车现场下面这些坑基本是我照着类似资源复现时真实踩过的。每一条都按现象、原因、解决三个层次记录遇到问题可以直接对照排查。5.1 现象预测时大量词语提示 not in vocabulary训练时一切正常一跑predict_similarity就发现很多词在model.wv里查不到打印出来全是KeyError或者not in vocabulary。原因最普遍的一种是训练和预测时分词结果不一致。比如训练时用jieba.cut(sentence)预测时却用了jieba.cut(sentence, cut_allTrue)同一个词被切成了不同粒度另一种是min_count5过滤掉了低频词这些词在训练阶段的词表里根本不存在。解决给项目固定一个分词函数训练和预测必须调用同一个。低频词过滤导致的 OOV要么降低min_count要么在预处理阶段引入一个自定义词典把评论里的品牌名、特色词提前加进 jieba 的用户词典。jieba.load_userdict(userdict.txt)5.2 现象词向量训练出来后最相似词全是“的”“了”“在”model.wv.most_similar(酒店)返回的结果里全是停用词或者相似度分数普遍虚高接近 1。原因停用词过滤没有生效。一种情况是stopwords.txt根本没被脚本读取路径写错了另一种是停用词集合是 set但比较时用了words not in stopwords而停用词表里的词带了换行符没 strip。解决在预处理脚本里加一行断言确认停用词真正生效assert 的 not in tokenize(这是一家很好的酒店)如果断言失败立刻检查 stopwords 文件路径和 strip() 逻辑。另外建议把min_count适当调高停用词出现频率极高min_count5以上本来就能过滤一部分但“的”“了”这种还是会留下来所以停用词表这一步不能省。5.3 现象Windows 上训练报 EOFError 或进程卡死在 Windows 机器上跑Word2Vec设了workers4训练刚开始就报EOFError或者程序卡在model.build_vocab()没有任何输出。原因gensim 的Word2Vec在 Windows 上使用多线程时有时会因为子进程的句柄继承问题报错workers大于 1 是触发条件。另外build_vocab阶段如果语料是LineSentence对象默认会先扫描一遍语料统计词频语料大时看起来就像卡死。解决遇到这个问题把workers临时改成 1先跑通小数据确认流程没问题了再考虑换 Linux 环境或多进程。build_vocab阶段没有进度条是正常的可以传progress_per10000让它每处理 1 万条打印一次进度。model Word2Vec( sentences, workers1, progress_per10000 )5.4 现象CSV 读取后中文字符全是乱码pandas 读入后 review 列显示为一堆乱码或者 DataFrame 的列名都错位了。原因文件编码和encoding参数不匹配。常见的是文件本身是 GBK 编码但代码默认用了utf-8或者文件是带 BOM 的 UTF-8而enginec的解析器不认 BOM。解决短平快的方案是先chardet探测再按结果传编码参数。换encodingutf-8-sig通常能解决 BOM 问题换encodinggbk能解决大部分 GBK 文件。如果这两招都不行用enginepythonencodinggb18030兜底gb18030 是 GBK 的超集容错更好。5.5 现象短文本相似度全是 0或者全是很接近的高分两条明显语义相关的短评论算出的相似度却是 0或者两条完全无关的评论相似度都集中在 0.9 以上。原因相似度全是 0说明其中一条评论分词后全部落在词表外句子向量是全零向量。全是很接近的高分说明停用词过滤不彻底模型被“的”“了”“是”这类高频词带偏了或者词向量没有归一化向量模长差异影响了余弦结果。解决先分别打印两条评论的sentence_vector看是否为全零向量再打印各自的 token检查是否都命中了model.wv。高频词干扰的问题回到停用词表和min_count上去调。对于归一化问题把词向量先单位化再平均def sentence_vector(tokens, model): vectors [] for token in tokens: if token in model.wv: vec model.wv[token] norm np.linalg.norm(vec) if norm 0: vectors.append(vec / norm) if not vectors: return np.zeros(model.vector_size) return np.mean(vectors, axis0)6. 进阶把 TF-IDF 加权带进词向量平均顺便给模型做个低成本体检词向量平均虽然简单但有一个明显的短板它把“酒店”和“性价比”放在同等权重可实际上“性价比”对这句评论的语义贡献远大于“酒店”。解决方法是引入 TF-IDF 权重让高频但无信息的词权重降低低频但有区分度的词权重升高。这一步不需要重新训练模型只是把平均改为加权平均。from collections import Counter import math def compute_idf(segmented_lines): doc_count 0 df Counter() for line in segmented_lines: tokens line.split() doc_count 1 for token in set(tokens): df[token] 1 idf {} for token, freq in df.items(): idf[token] math.log((doc_count 1) / (freq 1)) 1 return idf idf_dict compute_idf(open(segmented.txt, r, encodingutf-8)) def tfidf_weighted_vector(tokens, model, idf_dict): vectors [] weights [] for token in tokens: if token in model.wv and token in idf_dict: vectors.append(model.wv[token]) weights.append(idf_dict[token]) if not vectors: return np.zeros(model.vector_size) vectors np.array(vectors) weights np.array(weights).reshape(-1, 1) return np.sum(vectors * weights, axis0) / np.sum(weights)这个脚本在算相似度之前做一次 IDF 预计算之后每次调用都很快。注意compute_idf里用了set(tokens)因为 IDF 统计的是“包含该词的文档数”同一个词在同一文档里重复出现不应该重复计数。权重改完我还习惯顺手给模型做一个“体检”跑一组most_similar看“酒店”的邻居词是否合理跑一组反义词对比确认“干净”和“脏”的余弦相似度是负的再抽几条评论人工打分看相似度排序是否符合直觉。这个小检查步骤不需要额外工具却是判断模型有没有训偏最快的方式。从那以后我每次训练完 Word2Vec都会强制先把这三个体检项过一遍再往下游任务走不然算出来的相似度再漂亮我心里也没底。希望帮到你。本文还有配套的精品资源点击获取
返回列表