NLP学习笔记[1] -- 构建词向量模型 -- Word2Vec与词嵌入

当获取到足够多的语料之后, 需要语料中的主要语素"可计算", 而"向量"是最普遍和容易使用的可计算元素, 把词语等语素编码成向量就是词向量; 词向量的计算应该有意义, 需要尽可能保留原来的语言特征, 把语素编码到连续的、能反映语素间关系的连续的向量空间的操作叫词嵌入.

(本文包含尝试和其他实验的笔记, 篇幅较长, 如果不想看细节的话可以直接跳到按手册说明调用word2vec节或者总结部分.)

本文使用的的语料库来自狗狗本地近期(2020年1月到5月)的QQ群消息记录(共2,121,085条消息). 在上一节使用pandas读取sqlite数据库并使用jieba分词和标注, 预处理结果存储到了CQevePPC.db中(出于尊重他人隐私, 不提供这个数据库).

本文涉及的内容

  1. matplotlib的综合绘图(中文字体管理, 自定义刻度, 自定义标记及标记文本)
  2. 第三方模块pandas的综合运用(条件筛选, 去重, 遍历)
  3. 第三方模块gensim.models.word2vec的介绍也基础使用
  4. 第三方模块sklearn.decomposition.PCA的介绍和简单运用(本例用于可视化词向量分布)

相关知识介绍

人类运用语言文字是依赖大量背景知识和人类特有的脑结构的, 而目前计算机不可能直接处理. 对于程序而言任何一个文字或者单词都仅仅只是一个"token"(所以word2vec不仅仅可以用来处理任何语种的自然语言, 实际上它能处理任何有关联的"标签", 以后有机会再讲), 这大概就类似一个没了解过德语也没了解过汇编的人类看到Wie geht escmp al, 0dh一样吧…

实现词嵌入的方式很多, “词嵌入的方法包括人工神经网络、对词语同现矩阵降维、概率模型以及单词所在上下文的显式表示等”(摘自百度百科). Word2Vec的主要实现就是使用神经网络的方法.

一般来说让计算机处理向量是比较适合的, 后端的各种形式的处理, 比如神经网络的输入, 通常也都是采用向量. 当然, 也可以人工建分类词典, 比如Wordnet .

所以, NLP的第一步也就是构建词汇的向量化表示模型. 把词汇向量化表示的方法不少, 主流的有Word2Vec, WordRank, FastText. 各有各的特色和适用范围, 具体介绍和评测可参考阿里云上的一篇帖子: 云栖社区-哪种词向量模型更胜一筹?
这里用Word2Vec主要是因为它的输出比较通用, 而且也最常用资料和案例也多.

谷歌开源的Word2Vec主要是实现通过单词和上下文彼此预测, 它提供了:

  • 两个语言模型: Skip-grams (SG, 跳字模型) 和 Continuous Bag of Words (CBOW, 连续词袋模型), 分别用于预测上下文和预测目标单词;
  • 两种加速算法:负采样(negative sampling)和层序softmax(hierarchical softmax).

Word2Vec本质上算是一种神经网(不过它没有激活函数) – 前级语言模型的one-hot向量作为输入, 乘输入权重矩阵W(随机初始化的)相加求平均作为隐层向量, 再乘输出权重矩阵W’(N*V)之后经过激活函数(一般是softmax)处理得到V-dim概率分布(一个维度代表一个词), 本质上是一个多分类问题. 输出之后, 最大概率索引表示的词称为中间词, 用它和实际语料的one-hot值比较, 通过训练来更新W和W’来减小误差.
其中: N是设定的超参, 指定隐层的大小; V是输出的维度; W和W’最初为随机值, 通过梯度下降法优化; 损失函数一般是交叉熵

总之就是提供了一种把词汇"嵌入"到一个数字空间的功能.

大致思路

狗狗开始(21 Jan. 2020)的想法是把每个群中的消息各成一组, 然后按时间筛选出可能是同一主题的对话(在时间分布上呈"群"的), 最后再提取每一个主题再重新整理时间群成对话群. 因为, 按一般逻辑来说, 时间相邻的每"一群"消息应该是上下文相关的一组对话(比如说在讨论什么话题).
一般来说无论通过何种方式构建词库表示, “喂给"模型的语料都应该是上下文相关的语料, 这样语言模型才能"学习到"词汇之间的关系. 所以整理出"conversation"之后无论用TF-IDF算法来获得每一组对话的主题词再整理成连续的"文章"还是通过别的什么聚类方法来整理都会变得容易.(不过我不确定是否一定要整理成连续的文章, 即语料的粒度是否会影响结果, 后文中还会做方案验证)
(后来才发现这个想法太理想, 实际上时间轴相邻的"conversation"可能压根就是"全频干扰”, 比如两个人正在聊着电子技术而另外三个人在聊着游戏还有给每个群都群发广告的… 潜水窥屏跟用SDR收音机技术似的… 更有极端情况是某个群无间隔地有新消息… 群员越多这种情况越明显)

具体操作

尝试: 通过时间分组消息以关联上下文 (非必要)

先验证一下, 观察实际情况以确定能否通过时间邻近的方式筛选出有上下文关系的"conversation". 这里采用绘图观察的方法来验证. 为了方便观察, 截取2020年1月23号4时~1月30号4时的消息(主要是这段时间消息比较丰富且我一直在线)

%%time
# 继续上一节用到的库
import time, datetime, sys, sqlite3 as sqlite
import numpy as np, pandas as pd
pd.set_option('max_colwidth',100); pd.set_option('display.max_rows', 20); pd.set_option('display.max_columns', 8)
# 使用logging模块既能方便调试又能充当注释
import logging
logging.addLevelName(11, "TIPS") # 自定义一个级别, 用于充当注释
printFormat = logging.Formatter('+ [%(levelname)6s] | %(asctime)s @%(relativeCreated)8d +: %(message)s')
consoleHandle = logging.StreamHandler()
consoleHandle.setFormatter(printFormat)
consoleHandle.setLevel(logging.DEBUG)
loggerP = logging.getLogger("ALL")  # `ALL`是随便起的名字, 也可以叫`root`之类的. 
    #因为logger是树形结构的, 每一个记录器会向上一层记录器节点传递自身的内容(也就是如果再开一个分支的话就`.getLogger("ALL.subLogger_Name")`), 不过这篇文章用不到那么多.
loggerP.addHandler(consoleHandle)   # 因为只是需要在notebook里显示而不需要日志文件, 所以这里就只添加一个StreamHandler, 如果想要同事输出到文件和控制台, 那就再加一个FileHandler就行. 
loggerP.setLevel(logging.DEBUG)
Wall time: 777 ms
Conn_ppced = sqlite.connect("CQevePPC.db")  # 上一节做的预处理结果数据库
DB_CQ = sqlite.connect("eventv2.db")        # 酷Q的事件系统日志数据库, 这里为了绘制消息分布情况
%%time
# 先观察看看原始数据的结构, 后面用`.itertuples()`获取数据
query_selectLog = "SELECT `id`, `tag`, `GROUP`, `account`, `operator`, `content`, `TIME` FROM `event` ORDER by `id` DESC;"
CQ中的原始记录 = pd.read_sql_query(query_selectLog, DB_CQ)
loggerP.debug( "变量`CQ中的原始记录`占用内存 → %0.3fMB" % (sys.getsizeof(CQ中的原始记录) * 1e-6) ) 
loggerP.log( 11, "可以看到, df非常占内存, ↑ 像这样的对象不会被python自动释放所以不用的时候要及时手动del掉变量" ) 
CQ中的原始记录
+ [ DEBUG] | 2020-07-03 17:57:49,691 @   38422 +: 变量`CQ中的原始记录`占用内存 → 865.802MB
+ [  TIPS] | 2020-07-03 17:57:49,693 @   38424 +: 可以看到, df非常占内存, ↑ 像这样的对象不会被python自动释放所以不用的时候要及时手动del掉变量


Wall time: 36 s
idtaggroupaccountoperatorcontenttime
02137871contactqq/group/222****74qq/user/265****118[CQ:image,file=782CC9F7A8AB917B66081B44F37D03E0.jpg]1588248860
12137870contactqq/group/708****08qq/user/284****339预计3ml(误1588248858
22137869contactqq/group/569****13qq/user/157****298[CQ:face,id=174]1588248858
32137868contactqq/group/152****28qq/user/133****521估计是怕热胀冷缩1588248858
42137867contactqq/group/222****74qq/user/265****118反正我现在开学也依旧活跃1588248856
........................
21378665qq/user/213****150来自616471607的私聊消息(4):1577880261
21378674contactqq/user/616****07291577880261
21378683contactqq/group/569****13qq/user/152****205像我这种小功率都内部接地1577880243
21378692contactqq/group/569****13qq/user/513****92[CQ:at,id=qq/user/142****809] [CQ:at,id=qq/user/142****809] 我就谁便用了啵1577880188
21378701contactqq/group/569****13qq/user/221****140[CQ:at,id=qq/user/152****205] [CQ:at,id=qq/user/152****205] 氖泡接在驱动电源负和地线之间1577880169

2137871 rows × 7 columns

# 如上文所述取春节期间的消息用于观察, 先做个裁剪以方便处理以及降低计算负担
起始时间 = "2020年1月23日04:00:00"
timeArray = time.strptime(起始时间 , "%Y年%m月%d日%H:%M:%S")
起始时间 = int(time.mktime(timeArray))
结束时间 = 起始时间 + 7 * 24 * 60 * 60
loggerP.info("截取时间段: 起始时间 → %d\t结束时间 → %d" % (起始时间, 结束时间))
+ [  INFO] | 2020-07-03 17:57:49,733 @   38464 +: 截取时间段: 起始时间 → 1579723200	结束时间 → 1580328000
%%time
# 先确定截取时间段中有哪些群活跃
群号列表 = []
allLog = CQ中的原始记录
截取段 = allLog[(allLog["time"]>起始时间) & (allLog["time"]<结束时间)]
for value in 截取段["group"]: 
    if value != "" and value[9:] not in 群号列表:
        群号列表.append(value[9:]) 
loggerP.debug("`群号列表`预览:\n::\n%s\n::" % (群号列表))
loggerP.info("截取时间段内有%d个群活跃" % len(群号列表) )
+ [ DEBUG] | 2020-07-03 17:57:49,823 @   38553 +: `群号列表`预览:
::
['569611113', '361342382', '58978661', '196175660', '893669216', '483537882', '672211591', '391231978', '1032393713', '477046277', '498945097', '567739600', '287390242', '962362386', '264297892']
::
+ [  INFO] | 2020-07-03 17:57:49,824 @   38554 +: 截取时间段内有15个群活跃


Wall time: 69 ms
%%time
# 通过绘图观察来验证, 看看实际上是否能通过时间邻近关系确定内容相关关系
bias_time = 起始时间  # 为了绘图(X轴刻度)方便, 把起始时间定为0
群消息记录  = []
tmp_count = 0
for 群号 in 群号列表:
    群消息记录.append([])
    for 记录 in 截取段.itertuples():  # 之前用的是.iterrows(), 虽然可读性高, 但是太慢了, 耗时30秒...
        if 记录[3] == ("qq/group/" + 群号列表[tmp_count]): 
            群消息记录[tmp_count].append([记录[7] - bias_time, 记录[6]])  # 数据点设计成(时刻, 来源)的形式
    tmp_count += 1
loggerP.debug("绘图数据`群消息记录`预览:\n::\n%s\n::共计%d条数据"%([["群`%s`:%d条"%(群号,len(群消息记录[群号列表.index(群号)]))]for 群号 in 群号列表], len(CQ中的原始记录)))
loggerP.debug( "变量`群消息记录`占用内存 → %0.3fKB" % (sys.getsizeof(群消息记录) * 1e-3) ) 
+ [ DEBUG] | 2020-07-03 17:57:50,851 @   39581 +: 绘图数据`群消息记录`预览:
::
[['群`569611113`:20543条'], ['群`361342382`:2367条'], ['群`58978661`:155条'], ['群`196175660`:430条'], ['群`893669216`:415条'], ['群`483537882`:673条'], ['群`672211591`:2368条'], ['群`391231978`:70条'], ['群`1032393713`:793条'], ['群`477046277`:28条'], ['群`498945097`:14条'], ['群`567739600`:4条'], ['群`287390242`:18条'], ['群`962362386`:7条'], ['群`264297892`:5条']]
::共计2137871条数据
+ [ DEBUG] | 2020-07-03 17:57:50,853 @   39583 +: 变量`群消息记录`占用内存 → 0.184KB


Wall time: 1.02 s
%%time
# 绘图库, 以便观察消息分布情况
from matplotlib import pyplot as plt
from matplotlib.ticker import MultipleLocator, FormatStrFormatter  # 用来设置坐标轴便于观察
from matplotlib.font_manager import FontProperties  # 用于自定字体以便处理中文
Wall time: 360 ms
%%time
#狗狗喜欢的一个中文字体 = FontProperties(fname='/home/hooke/.local/share/fonts/sarasa-monoT-sc-medium.ttf').get_name()  # Linux
狗狗喜欢的一个中文字体 = FontProperties(fname=r'C:\Users\Hooke\AppData\Local\Microsoft\Windows\Fonts\sarasa-monoT-sc-medium.ttf').get_name()  # Windows
plt.rcParams['font.monospace'] += [狗狗喜欢的一个中文字体]  
plt.rcParams['axes.unicode_minus'] = False
plt.figure(dpi=96, figsize = (22, 8))
plt.title(u"狗狗本地消息记录中各群春节期间消息时间分布图", fontproperties=狗狗喜欢的一个中文字体, size=20)
plt.ylabel(u"来源群(群号)", fontproperties=狗狗喜欢的一个中文字体, size = 16)
plt.xlabel(u"计时秒数(自%s开始计时)"%time.strftime("%Y年%m月%d日 %H时%M分%S秒", time.localtime(bias_time) ), fontproperties=狗狗喜欢的一个中文字体, size=16)
for 群号索引 in range(len(群号列表)):
    plt.plot([time for time, msg in 群消息记录[群号索引]], [群号索引 for i in range(len(群消息记录[群号索引]))], color='#3d62f5', marker='|', markersize=18, linewidth=0)
ax = plt.gca()
ax.yaxis.grid(True,color='r', linestyle ='--', linewidth=1.2, alpha=0.4)
ax.xaxis.grid(True,color='b', linestyle ='--', linewidth=1, alpha=0.2)
ax.xaxis.set_major_locator(MultipleLocator(60 * 60 * 6))
plt.yticks(size=8); plt.yticks(ticks = np.arange(len(群号列表)), labels=群号列表, fontproperties=狗狗喜欢的一个中文字体, size=10)
plt.xticks(size = 8); plt.xlim(-3600, 结束时间 - 起始时间 + 3600)
plt.savefig("./狗狗本地消息记录中各群消息时间分布图.png", dpi=144, bbox_inches='tight')
loggerP.info("绘制了%d个数据点" %  len(CQ中的原始记录) )
+ [  INFO] | 2020-07-03 17:57:52,462 @   41192 +: 绘制了2137871个数据点


Wall time: 1.23 s

群聊消息记录:群号-时间分布图

绘制并观察这个图, 方案验证表明开始的想法不可行, 很多时候时(时刻)空(来源)相邻的对话在内容上是没有关联的(比如自说自话的、同一个群里同时讨论多个不相干的问题)…这也就是说, 我花了5天做的上面这些方案验证都白费了…(其实应该实际观察一下, 不被定势思维干扰)不过吧, 我还是得出了一个结论 – 嗯…我的群居然不是最冷的(多么痛的领悟)…

直接训练词向量模型

把上一节的语料库"喂"给Word2Vec

# 这里需要提一下`gensim`库, 这个库本身是用来实现`主题模型`的, 即用于从文档中自动提取语义主题. 不过它非常全面且"多功能", 本文说的word2vec也集成在了gensim中.
%time from gensim.models import Word2Vec
Wall time: 1.3 s

注, 在aarch64平台上有个坑 – gensim依赖scipy, 可是pypi-scipy上没有提供aarch64的包, 只能参考Building from source on Linux自行编译安装. 当然, 用Windows(amd64)就没这些问题了…(别问我为啥非要用aarch64和Linux, 问就是穷…疯狂暗示…)

语料处理: 清洗和格式化

# 先把上一节的预处理结果读出来, 还需要一些处理才能"喂"
query_selectLog = "SELECT * FROM `ppcLog` ORDER by `lid` DESC;"
%time 预处理好的语料库 = pd.read_sql_query(query_selectLog, Conn_ppced)
loggerP.debug( "变量`预处理好的语料库`占用内存 → %0.3fMB" % (sys.getsizeof(预处理好的语料库) * 1e-6) ) 
Wall time: 5.83 s


+ [ DEBUG] | 2020-07-03 17:58:16,118 @   64848 +: 变量`预处理好的语料库`占用内存 → 495.606MB

按一般经验来看, 一个句子一般至少要有三个语素并且含有至少一个名词, 否则很难确定含义, 但考虑到这里实际情况是随意的聊天对话, 句子成分少的很可能是一些不明确的回复. 所以这里经验按剔除len(jiebaPSEG) <= 4的记录. 此外, 如果是带图片的消息则很可能是用处不大的图片描述或者干脆是广告, 也去掉.

# word2vec输入需要结构: `[[jieba分词结果0], ['来自', '616471607', '的', '私聊', '消息'], ..., ["xxx",..."yyy"]]`
# word2vec需要使用上下文语境来定义词汇的分布, 上下文即同一层级的句子.
# 喂给wv的语料最好不要有太多重复, 这里做个去重和去空的操作.
%time 语料库 = 预处理好的语料库[(预处理好的语料库.jiebaPSEG.str.extract(r"('\w+', ){4,}", expand=False).notna()) & (~预处理好的语料库.cqCode.str.contains("CQ:image,file="))]
%time 语料库 = pd.DataFrame(语料库, columns=['jiebaCUT','jiebaPSEG'])
tmp_语料库_len = len(语料库)
%time 语料库.drop_duplicates(subset=['jiebaCUT'],keep='first',inplace=True)
loggerP.debug( "去重操作删除了%d行" % (tmp_语料库_len - len(语料库)) ) 

loggerP.debug( "变量`语料库`占用内存 → %0.3fMB" % (sys.getsizeof(语料库) * 1e-6) ) 
Wall time: 2.25 s
Wall time: 33 ms


+ [ DEBUG] | 2020-07-03 17:58:18,600 @   67330 +: 去重操作删除了11187行


Wall time: 183 ms


+ [ DEBUG] | 2020-07-03 17:58:19,734 @   68464 +: 变量`语料库`占用内存 → 66.052MB
loggerP.log(11, f"由于数据库里存的是`TEXT`形式. 比如\n|`type(语料库.iloc[61647].jiebaCUT)` \n|\t{语料库.iloc[61647].jiebaCUT} → {type(语料库.iloc[61647].jiebaCUT)}\n|\t所以需要用`eval()`这个用于解释字符串的函数返回真正的list")
+ [  TIPS] | 2020-07-03 17:58:19,743 @   68473 +: 由于数据库里存的是`TEXT`形式. 比如
|`type(语料库.iloc[61647].jiebaCUT)` 
|	[['早上', '吃', '牛肉', '吃', '完', '喝粥']] → <class 'str'>
|	所以需要用`eval()`这个用于解释字符串的函数返回真正的list
%%time
词表 = []
for row in 语料库.itertuples():
    # 0 -- 索引号; 1 -- jiebaCUT; 2 -- jiebaPSEG
    if row[1].find("转义E") < 0 and row[1].find("【未知含义】") < 0 and len(row[1]) > 5:
        词性 = eval(row[2])
        for 句子号, 句子 in enumerate(eval(row[1])): # 在这里, 因为前端生成的序列化列表是可靠的, 所以直接eval即可无需考虑安全性
            句子的词们 = list(句子)
            if len(句子的词们) > 3 and ('n' in 词性[句子号] or 'nz' in 词性[句子号]): # 不含有名词的句子以及过短的句子难以利用
                词表.append(句子的词们) 
loggerP.info("裁剪并拼接得到%d个句子" % len(词表) )
+ [  INFO] | 2020-07-03 17:58:31,498 @   80228 +: 裁剪并拼接得到135936个句子


Wall time: 11.7 s
loggerP.debug("`语料表`预览:\n::\n%s\n::" % (词表[217:233]))
loggerP.debug( "变量`词表`占用内存 → %0.3fKB" % (sys.getsizeof(词表) / 1000) ) 
+ [ DEBUG] | 2020-07-03 17:58:31,509 @   80239 +: `语料表`预览:
::
[['板子', '还是', '要', '优化', '一下', '炸', '一个', '我', '拆掉', '全部', '换', '一个', '想', '吐血'], ['管子', '外置', '好多', '炸', '一个', '换', '一个', '方便', '多'], ['输出', '拉', '他个', '五', '米线'], ['我', '这么', '保守', '玩', '是', '保守', '拆下来', '换', '一个', '想', '吐血'], ['RJ45', '的话', '不怕', '被', '电'], ['四管', '暴动', '应该', '可以', '这样', '我', '拉', '16', '管', '分担', '压力', '我', '想着', '超频', '做', '的', '保守', '战'], ['要是', '弧', '劈', '到', '那条', '线', '的话'], ['用', '电缆', '网线', '不会', '变成', '干扰', '接收器'], ['和', '用', '同轴', '的', '啥', '两样'], ['大声', '尖叫', '给', '你', '电', '萎', '咯'], ['像', '汽车', '输出', '120', '匹', '马力', '是', '原', '工厂', '设置', '的', '但是', '机器', '改装', '下', '可以', '输出', '200', '马力', '以上', '叫', '保守', '稳定', '输出', '长寿命'], ['这', '货', '的', '开关电源', '据说', '好坏'], ['这', '叫', '工程师', '专用', '尺子'], ['没', '游标卡尺', '和', '普通', '卡尺', '好使'], ['缺德', '的', '没', 'NVIDIA', '好看'], ['不同', '的', '是', '这', '上面', '还有', '各种', '封装', '的', '尺寸']]
::
+ [ DEBUG] | 2020-07-03 17:58:31,511 @   80241 +: 变量`词表`占用内存 → 1174.040KB

得到了这么一个二维列表就可以作为语料丢给Word2Vec去训练了. 当然, 在生成好语料表之后还应该人工检查一下(比如没去干净的URL、无实际意义的特殊成分比如艾特之类的)并根据实际情况调整修改预处理流程, 需要保证语料是通顺、有意义的.

按手册说明调用word2vec

这里先简单介绍一下gensim.models.word2vec的常规用法(还有高级用法就不赘述了), 参考word2vec tutorial总结如下:

Word2Vec有几个主要参数: sentences(list), min_count(int 0~100), size(int), sg(0|1), window(int), workers(int):

sentences: 语料表. (二维list, 或者其他句子[词...]...形式的可迭代对象)
min_count: 裁剪掉出现次数少于这个数量的词, 因为某些词可能是分词出错或者压根就是打字打错. 按语料库规模取0~100的整数值. (默认5)
size: 模型的网络规模(也就是词向量的特征维度), 取值越大(适当范围)且语料库越大得到的效果越好. (默认100)
sg: 设置训练算法. 0: CBOW算法; 1: skip-gram算法. (默认0)
window: 预测窗口(往前和往后分别看的最大距离, 随机范围)限制. 数值越大(适当范围)则关联的词数越多就越偏向句子整体含义; 数值越小越偏向于语法. (默认5)
workers: (依赖于Cython)并行进程数. (默认1)
iter: 迭代次数(可用于控制拟分布差异度?). (默认5)
batch_words:每一批的传递给线程的单词的数量,(默认10000)
hs: 加速计算的方法. 0: negative sampling; 1: hierachica softmax. (默认0)

%%time 
词向量模型 = Word2Vec(sentences=词表, min_count=180, size=96, window=3, iter=32, workers=3)
Wall time: 25 s
词向量模型.save('tmpTest_CQ.wvMod') # "训练"得到的词向量模型可以保存到文件以便以后使用(比如追加训练之类的)
# 大致瞅瞅都取出了哪些词
抽取出的词 = list(词向量模型.wv.vocab)
loggerP.info("训练建立 %d 个词的向量" % len(抽取出的词) )
预览 = (抽取出的词[:50] + 抽取出的词[-50:]); 预览.insert(50, "|# ... #|")
print( 预览 )
+ [  INFO] | 2020-07-03 17:58:56,530 @  105260 +: 训练建立 688 个词的向量


['驱动', '电源', '和', '像', '我', '这种', '小', '功率', '的', '有', '一个', '电容', '如果', '接', '在', '到', '电压', '时候', '直接', '还有', '供电', '可', '中间', '这样', '干', '发现', '这', '咋', '送', '人', '要', '比', '板子', '好', '看', '视频', '估计', 'ZVS', '烧', '比如', '主要', '是', '短路', '级', '不用', '考虑', '变压器', '你', '高', '管子', '|# ... #|', '作业', '个人', '之', '狼', '新手', '杀', '下载', '程序', '于', '怎么样', '创', 'PCB', '骂', '主板', '笑', '硬盘', '讲', '机械', 'QQ', '网络', '够', '来说', '孩子', '哈哈哈', 'C', '肝', '任务', '耳机', '好友', '选', '口罩', '疫情', '认识', '小马', '官方', '兽', '星', '原理图', '老婆', '设', '玩家', '女生', '公主', '音游', '邦', 'ARC', '魔王', '模板', '柒', '大木']

获取查看词向量

# 可以大致试一下用训练好的模型查看和某个词最相关的词.
想要预览关系的词 = ['万用表','CPU','短路','喵','12','好', '电压', '热', '电源', 'K', '线圈', 'ZVS']; 显示相近词的个数 = 6
forin 想要预览关系的词:
    ifnot in 抽取出的词:
        print(f"`\033[22;36m{词}\033[m`的词向量\033[01;33m未建立, 跳过...\033[m", end="\n\n")
        continue
    相近词们, 邻近度们 = [], []
    for res in 词向量模型.wv.most_similar([],topn=显示相近词的个数):
        相近词们.append(res[0])
        邻近度们.append(res[1])
    词向量 = 词向量模型.wv.__getitem__()
    print(f"`\033[22;36m{词:>4}\033[m`的词向量: \033[22;37m{词向量[:5]} [...省略86个值...] {词向量[-5:]} ({len(词向量)}维)\033[m")
    print(f"|  `\033[22;36m{词:>6}\033[m`最相近的{显示相近词的个数}个词:", end=" ")
    for 相近词, 邻近度 in zip(相近词们, 邻近度们):
        print(f"\033[01;33m{相近词}\033[m({round(邻近度 * 100, 2)}%)", end="\t")
    print("\n")
`[22;36m 万用表[m`的词向量: [22;37m[ 0.79026383 -0.9611095   1.268203    0.6092968  -0.15621881] [...省略86个值...] [ 0.12491999  0.38392118 -1.4869517  -1.6472396   0.45763397] (96维)[m
|  `[22;36m   万用表[m`最相近的6个词: [01;33m示波器[m(54.06%)	[01;33m电源[m(43.68%)	[01;33m芯片[m(42.76%)	[01;33mCPU[m(41.32%)	[01;33m信号[m(40.83%)	[01;33m电阻[m(40.75%)	

`[22;36m CPU[m`的词向量: [22;37m[ 0.33410102 -0.5538861   0.23603994 -0.34773296  0.6981396 ] [...省略86个值...] [ 0.29188916 -0.118932   -0.42300823 -0.7971877   0.03690784] (96维)[m
|  `[22;36m   CPU[m`最相近的6个词: [01;33m主板[m(59.04%)	[01;33m内存[m(54.87%)	[01;33m硬盘[m(50.94%)	[01;33m电池[m(46.71%)	[01;33m笔记本[m(45.0%)	[01;33mG[m(43.67%)	

`[22;36m  短路[m`的词向量: [22;37m[-0.13396409  1.5533133   1.1348873   0.96777016  0.67336106] [...省略86个值...] [-0.6531447  -0.5301099  -0.72078025  0.20162311  1.9498444 ] (96维)[m
|  `[22;36m    短路[m`最相近的6个词: [01;33m电压[m(62.79%)	[01;33m输出[m(57.5%)	[01;33m接[m(56.41%)	[01;33m电流[m(51.99%)	[01;33m220[m(51.42%)	[01;33m高压[m(50.55%)	

`[22;36m喵[m`的词向量[01;33m未建立, 跳过...[m

`[22;36m  12[m`的词向量: [22;37m[-0.41551644 -1.5263643   0.75593126  0.34289122  0.3662628 ] [...省略86个值...] [-0.6019385   0.12556106 -0.05031314 -0.4265495   0.01747075] (96维)[m
|  `[22;36m    12[m`最相近的6个词: [01;33m5[m(71.96%)	[01;33m24[m(71.3%)	[01;33m6[m(62.27%)	[01;33m10[m(59.77%)	[01;33m30[m(59.22%)	[01;33m15[m(58.52%)	

`[22;36m   好[m`的词向量: [22;37m[-0.5960649  -0.5069479  -0.57379526  0.5113793  -0.4094371 ] [...省略86个值...] [-2.6951045e-01  8.4344938e-04 -1.2243812e+00 -1.1676579e+00
 -5.0290203e-01] (96维)[m
|  `[22;36m     好[m`最相近的6个词: [01;33m好看[m(49.86%)	[01;33m便宜[m(43.53%)	[01;33m一点[m(38.89%)	[01;33m简单[m(37.41%)	[01;33m不错[m(37.11%)	[01;33m算了[m(34.09%)	

`[22;36m  电压[m`的词向量: [22;37m[0.8212819 0.6903777 1.1975476 0.9296892 1.0460272] [...省略86个值...] [-1.135864   -0.8664684  -0.96603394 -0.19411911  0.642267  ] (96维)[m
|  `[22;36m    电压[m`最相近的6个词: [01;33m电流[m(73.82%)	[01;33m输出[m(73.56%)	[01;33m输入[m(66.79%)	[01;33m频率[m(63.28%)	[01;33m信号[m(63.19%)	[01;33m短路[m(62.79%)	

`[22;36m   热[m`的词向量: [22;37m[-0.05732917 -0.64290935 -0.2644241   0.37783083 -0.2457129 ] [...省略86个值...] [-0.4708399  -0.49013263 -0.19708645 -0.63045096 -0.20183808] (96维)[m
|  `[22;36m     热[m`最相近的6个词: [01;33m发热[m(48.96%)	[01;33m大[m(44.08%)	[01;33m小[m(41.58%)	[01;33m高[m(41.45%)	[01;33m管子[m(39.02%)	[01;33m便宜[m(38.26%)	

`[22;36m  电源[m`的词向量: [22;37m[-0.7357667   0.1840699   1.2461534  -0.10229236  0.03712739] [...省略86个值...] [-1.2777146  -0.03374714 -1.0298015  -1.5821644   0.8945413 ] (96维)[m
|  `[22;36m    电源[m`最相近的6个词: [01;33m供电[m(61.25%)	[01;33m电机[m(58.46%)	[01;33m芯片[m(58.03%)	[01;33m电池[m(57.79%)	[01;33m风扇[m(56.46%)	[01;33m变压器[m(55.78%)	

`[22;36m   K[m`的词向量: [22;37m[ 0.94778633 -1.1426208  -0.802746   -1.1789104   1.0238255 ] [...省略86个值...] [ 0.99266344 -0.2047291   0.98211277 -0.508358   -1.0758587 ] (96维)[m
|  `[22;36m     K[m`最相近的6个词: [01;33mM[m(69.71%)	[01;33mG[m(64.45%)	[01;33mW[m(64.38%)	[01;33m元[m(60.81%)	[01;33mA[m(57.05%)	[01;33m块[m(53.69%)	

`[22;36m  线圈[m`的词向量: [22;37m[ 0.62975323  0.8500889   1.1290426  -1.3998194  -0.71288514] [...省略86个值...] [-0.34671775 -0.39654815 -0.6864894   0.09136111  0.40432522] (96维)[m
|  `[22;36m    线圈[m`最相近的6个词: [01;33m次级[m(57.52%)	[01;33m初级[m(54.4%)	[01;33m变压器[m(53.96%)	[01;33m高压包[m(50.74%)	[01;33m电容[m(49.61%)	[01;33m电感[m(46.75%)	

`[22;36m ZVS[m`的词向量: [22;37m[ 0.6410627   0.41143662  0.12133974 -0.17753209 -1.2516489 ] [...省略86个值...] [-0.43448713 -0.06623789  0.99241126 -2.2465672   0.70846575] (96维)[m
|  `[22;36m   ZVS[m`最相近的6个词: [01;33m全桥[m(64.79%)	[01;33m高压包[m(64.66%)	[01;33m三极管[m(57.45%)	[01;33m驱动[m(56.21%)	[01;33m自激[m(55.02%)	[01;33m电容[m(54.97%)	

Word2Vec为词频高于指定值的词都建立的词向量, 并且通过window(前后各1~window个随机的词)可找到词语间的关系. 上面这些相近关系不是很准确不过也还算可接受, 毕竟数据集是群聊消息记录, 群聊这种场景本身的语言特征就比较混乱.

可视化模型

为了更直观地了解这个模型的效果, 数据可视化是首选, 这里就用PCA(主成分分析, 能将现有的特征进行某些变换并选出最能表达该数据集的几个特征)模块来做2D可视化. 在python中可以方便地使用sklearn(全称Scikit-learn, 是机器学习中常用的第三方模块,对常用的机器学习方法进行了封装.)中的sklearn.decomposition.PCA

# 用PCA做2d拟合并绘图
from sklearn.decomposition import PCA
词向量矩阵 = 词向量模型.wv.__getitem__(词向量模型.wv.vocab)
result = PCA(n_components=2).fit_transform(词向量矩阵) # 指定PCA模型为2维
words = list(词向量模型.wv.vocab)
# 做个限幅, 有些太离散的点影响可视化效果就丢弃不绘制
待弃点索引 = []#[ 索引 for 索引 in range(len(result)) if result[索引, 0] < 0 or result[索引, 0] > 6 or result[索引, 1] > 2 or result[索引, 1] < -4 ]
result_plt = np.delete(result, 待弃点索引, axis=0)
words_plt = [ words[索引] for 索引 in range(len(words)) if 索引 not in 待弃点索引 ]
%%time
# 绘图, 以散点图的形式可视化
plt.figure(dpi=96, figsize = (18, 10)); plt.title(u"狗狗本地QQ群消息记录中词向量模型2D可视化分布图", fontproperties=狗狗喜欢的一个中文字体, size=20)
plt.scatter(result_plt[:, 0], result_plt[:, 1], s=4)
for i, word in enumerate(words_plt):
    plt.annotate(word, xy=(result_plt[i, 0], result_plt[i, 1]), fontproperties=狗狗喜欢的一个中文字体, size=10)
# 调整坐标以便观察

%time plt.savefig("./WordEmb_2D.png", dpi=144, bbox_inches='tight')
Wall time: 4.63 s
Wall time: 4.7 s

demo词向量模型PCA二维可视化

使用PCA提取特征, 把上述词向量空间这个96维的空间"降维打击"成了上面这张图(二向箔?). 图中的每一个点相当于降维后的词向量, 从原点(0,0)到某个词的向量即该词在二维投影的词向量. 从这个图也能大致看出词之间的关联性(其实所谓的关联性是余弦相似度, 是那个原本的词向量空间里的向量余弦值, 降维到2维变得不太明显), 比如, “供电"到"输出”、到"电压"、到"电流"这三条线的长度很接近.

总结

Word2Vec构建了一个指定维度的"空间", 从语料中提取词和词之间的关联, 为一部分词在这个"空间"里"安家", 每一个词被编码成指定维度的向量.
词向量间可以用余弦相似度反映关联性.
Word2Vec的"上下文"指的是同一个句子里的其他词, 句子和句子之间没有关联, 没必要考虑分组.
词向量可以直接作为下一级神经网络的输入.

以上就得到了一个语料库里主要词汇们的词向量模型了. 每个主要词都以一个向量来表示, 并且这些向量之间保留了原始语言结构的特征, 下一篇开始尝试使用类RNN的模型来"理解"一句话的含义.


本文内容仅为个人见解和心得, 希望能带来帮助. 如果有不正确/不准确之处还请多多指教, 一起学习一起进步~

本文内容由佚之狗原创, 可以随意使用但请注明出处.

如果希望这样的文章能更多更好的话, 那就不妨给打赏点吧~

打发点呗~
  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值