ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从CSV到文本相似度:中文Word2Vec预处理与训练实操

从CSV到文本相似度:中文Word2Vec预处理与训练实操

简介:面向自然语言处理初学者和文本挖掘开发者,这份资源围绕中文文本预处理、Word2Vec词向量训练及文本相似度计算展开。包内包含两个Python脚本和一个CSV数据集,共3个文件,压缩包整体约1.19MB,轻量但覆盖完整流程:一个脚本处理原始中文语料,完成数据导入、清洗、分词、停用词过滤等关键环节;另一个脚本基于处理后的语料训练Word2Vec模型,并利用余弦相似度或欧氏距离衡量文本间语义距离。配套的CSV文件是带标注的中文情感分类语料,可直接用于模型训练与效果验证。整份资源适合做入门实践或课程作业参考,已有658人学习。通过运行脚本,读者可掌握从原始中文文本到词向量表示再到相似度计算的全链路方法,理解CBOW与Skip-gram两种架构的差异,并学会调节窗口、维度和迭代次数等参数。对于需要快速搭建中文NLP实验环境、理解词向量原理的读者,是一份便于直接上手的学习资料。

1. 中文文本预处理与 Word2Vec:这份资源把「从原始 CSV 到相似度数值」的整条路串通了

做中文 NLP 项目的人,多半在 word2vec 文本预处理这一环上栽过跟头。直接从开源仓库拉一段语料,连 tokenize 都不做就丢进 gensim,训练出来的词向量拿去算文本相似度,结果两个毫不相关的句子得分比同义句还高。这份资源(Word2vec-with-Chinese-character-master)解决的正是这个问题:它用两个 Python 脚本把「原始 CSV → 清洗 → 分词 → 停用词过滤 → Word2Vec 训练 → 相似度计算」这条链路完整串了起来,数据用的是 ChnSentiCorp_htl_all.csv 酒店评论语料。适合刚接触中文词向量的开发者,也适合想快速验证预处理流程对结果影响的从业者。下面按我实际跑通的顺序拆解。

2. 文本预处理全流程:从酒店评论 CSV 到干净分词语料的四个环节

文本预处理是整个流程的地基,地基没打牢,后面训练出来的词向量就是一坨乱码映射。这个环节做的事可以用一句话概括:把人类写的、带噪声的原始文本,变成一组组干净的、符合模型输入格式的词语列表。下面按数据导入、清洗、分词、停用词过滤四个环节展开,每一步我都会给出脚本里的对应写法和可以改的参数。

2.1 数据导入:编码与缺失值这两个坑必须一开始就处理

第一步是用 pandas 把 CSV 读进来。这里有个很容易翻车的细节:Windows 环境下生成的 CSV 经常带 BOM 头,直接用默认编码读,第一列列名会多出一个\ufeff,后面所有基于列名的操作全部报 KeyError。资源里用的是utf-8-sig,这个编码会自动剥离 BOM,属于中文语料读取的默认选项。

import pandas as pd df = pd.read_csv( "datasets/ChnSentiCorp_htl_all.csv", encoding="utf-8-sig", dtype={"label": int, "review": str} ) print(df.shape) print(df.isnull().sum()) df["review"] = df["review"].fillna("")

这段代码里dtype参数容易被忽略,但它很重要。评论列如果混入纯数字内容,pandas 可能把整列推断成 float,后续做字符串操作直接报错。显式声明成str能避免这种类型推断的玄学问题。fillna("")处理的是缺失值,因为 CSV 里偶尔会有空行或空字段,不填掉的话,下一步正则清洗时None会抛异常。

提示:如果数据集很大,读入后先打印df.shape和df.head()确认行数、列名符合预期,再做清洗,避免清洗了半天发现读错文件。

2.2 清洗与标准化:正则把噪声挡在模型之前

清洗的目标是去掉对语义贡献为零的噪声。酒店评论里最常见的是 HTML 标签、URL、数字、特殊符号,以及全角半角混用的问题。脚本里用正则做第一轮过滤,再用 Unicode 标准化统一字符宽度。

import re import unicodedata def clean_text(text: str) -> str: text = re.sub(r"<[^>]+>", "", text) # 去 HTML 标签 text = re.sub(r"https?://\S+|www\.\S+", "", text) # 去 URL text = re.sub(r"\d+", "", text) # 去数字 text = re.sub(r"[^\u4e00-\u9fa5\uFF01-\uFF5E\w]", "", text) # 去特殊符号 text = unicodedata.normalize("NFKC", text) # 全角转半角 return text

逻辑说明:前四个re.sub是层层过滤,顺序有讲究——先去掉标签和 URL,是因为它们内部可能包含数字和特殊符号,先删可以避免后面正则误伤;第四行是白名单式过滤,只保留中文、中文标点、字母数字下划线,其余全部剔除。最后的normalize("NFKC", ...)是容易被忽略的一步,它把全角字母数字(如good)转成半角(good),否则同一个词会被分词的字典当成两个完全不同的 token,在 Word2Vec 里占两个向量位置,白白稀释语料。

清洗强度要按场景调整。情感分析任务里数字和 URL 基本没语义,可以直接删;但如果是电商评论,「2024」这种年份可能有意义,就别用\d+一刀切。我的习惯是先跑一遍清洗,打印 20 条样本人工扫一遍,再决定要不要放宽正则。

2.3 jieba 分词与停用词:给 Word2Vec 喂对语料

中文分词是把连续字符串切成有意义的词序列。资源用的是 jieba,因为它对酒店评论这类口语化文本的切分表现尚可,而且支持自定义词典。分词这一步的输出是 Word2Vec 的直接输入,脚本里做了两个关键处理:过滤空白 token、过滤停用词。

import jieba import jieba.analyse def load_stopwords(path: str) -> set: with open(path, "r", encoding="utf-8") as f: return {line.strip() for line in f if line.strip()} def segment(text: str, stopwords: set) -> list: words = jieba.lcut(text) return [w for w in words if w.strip() and w not in stopwords]

jieba.lcut返回的是 list,比jieba.cut这种生成器更直观,方便后面直接做列表推导。停用词表是外部文件,常见的有哈工大停用词表、百度停用词表,资源里没有显式列出我用的是自备的一份通用表,包含「的」「了」「是」「在」这类高频无实义词。segment函数里先strip()再查集合,是因为 jieba 偶尔会切出空格或空串,不过滤会让 Word2Vec 训练出无意义的向量。

分词环节有个容易犯的错:把自定义词典加载写在分词函数内部,每调一次segment就load_userdict一次。如果评论有几万条,这个加载过程会重复几万次,性能直接拉垮。正确做法是在主程序里加载一次,然后传给分词函数。词性标注和词形还原在脚本里是选配,因为 Word2Vec 只需要词形,不需要词性;如果要做更深层的语义分析,可以在这步输出带词性的元组,但那份资源没做,我们也不引入额外复杂度。

stopwords = load_stopwords("stopwords.txt") corpus = [segment(clean_text(row), stopwords) for row in df["review"]] corpus = [s for s in corpus if len(s) >= 2]

最后一行过滤掉分词后不足两个词的样本,这类短样本通常是空评论或纯噪声,留着只会让模型把低质量数据也学进去。整个预处理链路的产出是corpus,一个列表的列表,每个内层列表代表一条评论的分词结果,这个结构就是后面 Word2Vec 训练的标准输入格式。

3. Word2Vec 训练:模型选型、关键参数与训练实验记录

预处理跑通之后,词向量训练就是整个资源的核心环节。这一章讲两个脚本里真正决定模型质量的东西:架构怎么选、参数怎么定。很多人训练词向量直接抄别人参数,训完也不看效果,这属于把黑匣子当真用。下面把选型和参数摸清楚。

3.1 CBOW 与 Skip-gram 怎么选:先看语料量和生僻词占比

Word2Vec 有两种架构,资源里词向量训练.py通过sg参数切换。CBOW 用上下文预测中心词,训练速度快,适合语料量大、词频分布均匀的场景;Skip-gram 用中心词预测上下文,对低频词更友好,训练慢一点,但语义细微差别的捕捉能力更强。

针对 ChnSentiCorp 这个数据集,几十层楼的酒店评论语料,词表规模不算大,我的选择是 Skip-gram。原因很直接:语料几千条,很多词出现次数很低,CBOW 在这种稀疏语料上会把低频词训成近似零向量,而 Skip-gram 即使低频词也能学到有区分度的表示。如果你手头是上亿词的通用语料,再切回 CBOW 不迟。

对比项CBOWSkip-gram
训练速度快慢
低频词效果一般好
适用语料规模大中小
语义精细度较粗较细
对应 sg 参数01

3.2 词向量训练参数:窗口、维度、迭代次数怎么定

参数设置是影响训练结果最直接的因素,也是我见过最多人乱填的地方。脚本里的核心参数可以拆成四组:向量维度、上下文窗口、采样策略、训练轮数。每一组都有对应的实际意义,理解了才能根据语料调。

from gensim.models import Word2Vec model = Word2Vec( sentences=corpus, vector_size=128, window=5, min_count=3, sg=1, negative=5, epochs=10, workers=4, seed=42 )

参数说明:vector_size是词向量的维度,128 是中小语料的折中值——太低语义信息装不下,太高训练慢而且容易过拟合;window是上下文窗口大小,5 意味着左右各看 5 个词,窗口太小语义不完整,太大引入噪声;min_count=3表示出现次数少于 3 的词直接丢弃,这个值设定的逻辑是把长尾噪声挡在词表外,避免模型为只出现一两次的词分配向量空间;sg=1选的是 Skip-gram;negative=5是负采样数量,负采样让模型只更新少量负例的权重,训练速度能提升好几倍,5 是效果和速度的平衡点;epochs=10是遍历语料的次数,语料小就多跑几轮,语料大三四轮就够;workers=4是并行线程数,设成 CPU 核心数就行;seed=42固定随机种子,保证每次训练结果可复现,这是做实验必须养成的习惯。

训练耗时跟语料规模直接相关。这份资源的数据集,我实测在普通笔记本上,128 维、10 轮迭代,跑完大概两三分钟,速度不是瓶颈。真正要盯的是训练日志里的 loss 变化——gensim 不开 verbose 的话看不到过程,我建议第一次训练加上word2vec日志级别,确认语料被正确加载、词表大小合理,再关掉日志跑全量。

3.3 训练与模型落盘:从内存到磁盘的完整动作

训练完成后,模型交互和落盘是最后一步。脚本里用的是model.save(),这里有一个很多人踩过的坑:model.wv.save_word2vec_format()和model.save()保存的内容不一样,前者只存词向量矩阵,后者存的是完整模型对象,包括训练状态。如果你之后还要增量训练,必须用save();如果只是拿来查相似度,save_word2vec_format就够了。

model.save("word2vec_htl.model") model.wv.save_word2vec_format("word2vec_htl.vectors", binary=False)

逻辑说明:第一行保存完整模型,之后能Word2Vec.load()回来继续训练,也能直接查向量。第二行把词向量导出成文本格式,每行是一个词和它的向量数值,方便用其他工具读取,比如可视化或向量数据库导入。binary=False表示存成明文文本,方便人工查看调试;数据量大的话可以设成True存二进制,加载更快。

注意:save_word2vec_format保存的文件没有词频信息,加载后无法用model.train()继续训练,只能做查询。所以如果你还想调参迭代,第二个文件完全依赖save()出来的那份模型。

4. 计算文本相似度:平均词向量加余弦相似度的实现与调参

词向量训练好后,最终目标是算文本相似度。这里有个常见的概念误区:Word2Vec 输出的向量是词的向量,不是句子的向量。要把一段文本变成一个向量,常见做法是取这句话里所有词的向量做平均,这个被称为平均词向量。脚本里就是按这个思路实现的,下面把代码和背后的限制都讲清楚。

4.1 从词向量到句向量:平均词向量与加权方案

平均词向量是最简单的句子向量化方式。实现时要注意两个边界:未登录词和全未命中词。未登录词是指训练语料里没出现过,模型里查不到向量的词;一条句子如果大部分词都未登录,平均出来的向量会退化成噪声。脚本里的处理方式是跳过未登录词,只在最后兜底一个零向量。

import numpy as np VECTOR_SIZE = 128 def sentence_vector(words: list, model) -> np.ndarray: vecs = [model.wv[w] for w in words if w in model.wv] if not vecs: return np.zeros(VECTOR_SIZE) return np.mean(vecs, axis=0)

逻辑说明:model.wv是词向量字典,w in model.wv做存在性检查,避免 KeyError。np.mean(vecs, axis=0)沿行方向取平均,得到的是一个 128 维的句向量。这段代码有个隐患:如果句子所有词都是未登录词,返回零向量,这个零向量和任何向量的余弦相似度计算都会出问题。我在应用层会加一层判断,句子向量为零向量时直接丢弃或降级处理,而不是继续参与相似度计算。

平均向量有个明显的短板:它把所有词一视同仁,但「酒店」「房间」这类主题词和「的」「了」这种高频词的贡献应该不一样。加权方案可以缓解这个问题,常见的做法是 TF-IDF 加权——每个词向量乘以它的 TF-IDF 权重再平均。实现时要先拿语料拟合TfidfVectorizer,得到每个词的权重,再套进平均公式。这属于进阶玩法,资源脚本里用的是无权重平均,因为它简单且在小语料上效果已经够用。

4.2 余弦相似度计算与判定阈值

句向量有了,相似度计算就变成向量空间的几何问题。余弦相似度衡量两个向量的方向一致性,范围在 -1 到 1 之间,值越大越相似。欧氏距离也常被提到,但它的值受向量长度影响大,对词向量这种高维稀疏空间不友好,所以主流还是用余弦。

def cosine_similarity(v1: np.ndarray, v2: np.ndarray) -> float: norm1 = np.linalg.norm(v1) norm2 = np.linalg.norm(v2) if norm1 == 0 or norm2 == 0: return 0.0 return float(np.dot(v1, v2) / (norm1 * norm2)) # 使用示例 sent_a = "房间很干净 服务员态度好" sent_b = "卫生条件不错 前台很热情" vec_a = sentence_vector(jieba.lcut(sent_a), model) vec_b = sentence_vector(jieba.lcut(sent_b), model) print(cosine_similarity(vec_a, vec_b))

逻辑说明:np.linalg.norm算 L2 范数,也就是向量的模长。分母先检查是否为零,防止除零错误。np.dot(v1, v2) / (norm1 * norm2)就是余弦公式的向量实现。相似度算出来之后,阈值怎么定是应用层的核心问题。根据我这批酒店评论语料多次跑的经验,阈值大概这么划:大于 0.75 可以认为是强相关(同话题、同情感倾向),0.55 到 0.75 是弱相关,小于 0.55 基本不相关。但阈值随语料和向量维度变化很大,换数据集必须重新标定,不能直接抄。

4.3 相似度查询的边界:未登录词与短文本

边界情况是最容易让线上系统翻车的地方。未登录词导致的零向量问题,4.1 已经提到;短文本是另一个大坑。比如两个句子都只有两个词:「好 吃」和「难 吃」,词向量平均之后,前者向量偏正向,后者向量把「难」的负向信息也平均进去了,余弦相似度可能反而很高。因为平均操作会把方向抵消,短文本里词的权重太大,一句的关键词直接主导整个向量。

对应策略有两个:一是短文本场景改用加权方案,让情感词权重更大;二是对相似度得分做后处理,文本长度过短的句子单独走规则匹配,不参与向量计算。资源脚本里没有这种后处理,但我在实际落地时一定会加一层长度过滤。另外,同一个词在不同语境下语义不同(比如「苹果」指水果还是手机品牌),词向量模型训练时没有语境标注,这属于平均词向量的先天限制,做相似度时心里得有数。

5. 避坑指南:预处理和训练阶段最常见的四个翻车现场

整个流程我从预处理跑到相似度计算,遇到过不少问题。有些是参数配错直接报错,有些是结果离谱但没报错——后者更可怕,因为它不会打断流程,只会悄悄污染你的结论。下面按「现象 → 原因 → 解决」写四个我踩过也帮别人排查过的坑。

5.1 自定义词典没生效,分词语料大批量出错

现象:往 jieba 里加了自定义词典,包括酒店名、菜品名等专有名词,但分词结果里这些词还是被切成单字,导致 Word2Vec 的词表里出现一堆毫无意义的单字向量。

原因:最常见的是jieba.load_userdict()调用位置不对。把加载函数写在每个分词函数内部,或者写在if __name__ == "__main__"块里但主程序实际没有执行到;还有一种可能是词典文件编码不对,jieba 要求 UTF-8,Windows 下存成 GBK 就会静默失败。

解决:在启动阶段全局加载一次,并且确认词典文件是 UTF-8 编码。加载后做一次冒烟测试,拿词典里的词直接jieba.lcut验证切分结果。我一般会写一行断言:assert "海底捞" in jieba.lcut("海底捞服务不错"),不过这里只是举例,词典里有什么词就断言什么词。断言过了再跑全量语料。

5.2 gensim 4.x 换了参数名,旧代码直接 TypeError

现象:把网上抄的旧代码粘进来,跑到Word2Vec初始化时直接报TypeError: got an unexpected keyword argument 'iter',或者size参数报错。

原因:gensim 4.0 做了一次大版本 API 清理,iter改名为epochs,size改名为vector_size。网上大量旧教程和博客用的是 3.x 写法,直接照搬必翻车。资源脚本如果按新版本写的没问题,但你自己扩展代码时很容易混用旧参数名。

解决:统一按新 API 写,所有旧教程里的size=100改成vector_size=100,iter=5改成epochs=5。如果你的环境里历史代码太多必须兼容旧版,可以包一层函数判断 gensim 版本,但新项目没必要这么干。另外注意,4.x 里model.wv.vectors代替了旧的model.wv.syn0,访问原始向量矩阵时别用旧属性。

5.3 全角半角不统一,"good"和"good"被当成两个词

现象:词表里出现一对长得几乎一样的词,一个全角一个半角,比如good和good,查询其中一个,另一个完全不相关。如果语料里有大量全角英文字母,词表大小会虚增,每个词的向量质量反而下降。

原因:清洗阶段没做 Unicode 标准化。简体中文文本里,全角英文字母和全角标点很常见——很多输入法默认输出全角字符,用户从 Word 或网页复制的内容尤其明显。分词工具对全角和半角是按不同字符处理的,所以切出两个不同 token。

解决:清洗函数里加一行unicodedata.normalize("NFKC", text)。这一行代码能把全角字母数字转半角,全角标点也一并转换。我在 2.2 强调过这一步,这里再说一次是因为它太容易被忽略,而后果出现得很晚——词表大了一圈,但你根本注意不到原因。做完标准化后,重建语料再训练,词表通常会缩小 5% 到 10%,算是有效排查信号。

5.4 词向量模型加载后无法增量训练

现象:加载之前保存的模型,想加一批新语料增量训练,调用model.train(new_sentences)后报错,或者训练完但旧词的向量全被破坏了。还有人把model.wv当成模型本身去加载,结果连.train方法都没有。

原因:model.save()保存的是完整模型,加载要对应Word2Vec.load();而model.wv.save_word2vec_format()只存词向量矩阵,加载后只能查相似度,不能继续训练。另一个坑是增量训练时没设置epochs,gensim 对增量训练默认的迭代次数会让旧向量被大幅覆盖,导致旧词的语义漂移。

解决:增量训练必须用Word2Vec.load()加载完整模型,然后显式指定epochs,而且值要比首次训练小,通常是 1 到 3。训练前先把新语料和旧语料合并,确保min_count和词表更新逻辑一致。如果只是想用词向量而不需要再训练,直接用KeyedVectors.load_word2vec_format()加载第二份文件,逻辑更清晰。我现在的习惯是:最终交付只导出word2vec_format文件作为只读词向量,开发阶段用save()保留训练状态。

6. 进阶:用 t-SNE 可视化和词类比验证词向量,拒绝玄学调参

训练完模型,先别急着算相似度,我强烈建议做一次质量验证——这步做完你才知道参数该往哪个方向调。验证方法有两种:直观法和类比法。直观法是用most_similar看一个词的相关词排序,比如查「早餐」,合理的结果应该是「早餐」附近出现「午餐」「晚餐」「自助餐」这类词。如果返回的全是「房间」「酒店」这类不相关词,说明语料太少或窗口太大,模型没学到词的语义。

print(model.wv.most_similar("早餐", topn=10)) print(model.wv.most_similar("干净", topn=10))

类比法更严格,经典测试是国王 - 男 + 女 ≈ 女王这种词向量加减。中文可以做类似的:model.wv.most_similar(positive=["服务员", "女"], negative=["男"]),看结果是否接近「女服务员」。词向量如果连基本的类比关系都做不出来,相似度计算的结果就不值得信。

from sklearn.manifold import TSNE import matplotlib.pyplot as plt words = ["早餐", "午餐", "晚餐", "服务", "前台", "房间", "干净", "卫生"] word_vecs = np.array([model.wv[w] for w in words if w in model.wv]) words_valid = [w for w in words if w in model.wv] tsne = TSNE(n_components=2, random_state=42, perplexity=5) coords = tsne.fit_transform(word_vecs) plt.figure(figsize=(8, 6)) for i, word in enumerate(words_valid): plt.scatter(coords[i, 0], coords[i, 1]) plt.annotate(word, (coords[i, 0], coords[i, 1])) plt.show()

参数说明:perplexity=5在词数量少于 30 时强制调小,否则 t-SNE 算法会输出警告甚至结果失真。fit_transform输出的coords是二维坐标数组。可视化时如果「早餐」「午餐」「晚餐」能聚成一簇,和「房间」「干净」拉开距离,说明词向量空间结构是合理的。

调参的逻辑也可以讲讲:如果同义词没有聚类,先用vector_size从 64 加到 128 看效果;如果高频词都挤在一起,说明窗口太大,从 5 收到 3;如果低频词向量质量差,把min_count从 3 降到 1,同时换sg=1让低频词多学几轮。每次只改一个参数,跑完做上面三种验证,对比结果再动下一个参数。这比我最早一股脑把所有参数调一遍高效得多——那时候调参全凭感觉,跑一晚出了结果也不知道哪个参数起作用,纯属浪费算力。

从那以后我每次训完词向量,都强制走一遍「most_similar抽查 + 词类比验证 + t-SNE 聚类」这三个动作,确认向量空间结构合理,才把模型交给下游算相似度。这个过程也能帮你判断这份资源里的脚本是不是真的跑通了——如果你做完可视化发现语义相关的词确实聚在一起,那整条链路就没问题,可以放心拿这个模型去做推荐、情感分析或者问答系统的向量检索了。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进