阿里灵杰_Task02_词向量介绍与训练

0x00 Abstract

  • 任务内容:
    • 使用任务 1 得到数据使用 gensim 训练词向量
    • 计算与 格力 相似的 Top10 单词
    • 使用词向量完成句子编码(例如单词编码为 128 维度,一个句子包含十个单词为 10*128)
    • 对句子编码 10*128 进行求均值,转变为 128 维度
    • 扩展:你能使用计算得到的词向量,计算 train.query.txt 和 corpus.tsv 文本的相似度吗(train 选择 100 条文本,corpus 选择 100 条文本)?
  • 学习资料:

0x01 使用 gensim 训练词向量

数据集读取

# 导入相关库
import numpy as np
import pandas as pd
import os
from tqdm import tqdm_notebook
# 读取数据集
corpus_data = pd.read_csv( "./data/corpus.tsv", sep="\t", names=["doc", "title"])
train_data = pd.read_csv("./data/train.query.txt", sep="\t", names=["query", "title"])
qrels = pd.read_csv("./data/qrels.train.tsv", sep="\t", names=["query", "doc"])
dev_data = pd.read_csv ("./data/dev.query.txt", sep="\t", names=["query", "title"])

# 将原文中 index 设为 df 的 index
corpus_data = corpus_data.set_index("doc")
train_data = train_data.set_index("query")
qrels = qrels.set_index("query")
dev_data = dev_data.set_index ("query")

# 查看一下刚刚导入的数据集
train_data.head()
qrels.head()
qrels.loc[1]["doc"]

# 查看一下前 19 个训练集的 query 与 doc 对应的字段
for idx in range(1,20): 
    print(
        train_data.loc[idx]["title"],
        "\t",
        corpus_data.loc[qrels.loc[idx]["doc"]]["title"])

美赞臣亲舒一段 领券满减】美赞臣安婴儿 A+亲舒 婴儿奶粉 1 段 850 克 0-12 个月宝宝 慱朗手动料理机 Braun/博朗 MQ3035/3000/5025 料理棒手持小型婴儿辅食家用搅拌机 電力貓 小米 WiFi 电力猫无线路由器套装一对 300M 穿墙宝家用信号增强扩展器

这里只截取一部分展示。

词嵌入

# 导入 jieba 分词
import jieba

# 先对一个字符串分词看看
" ".join(jieba.cut("慱朗手动料理机"))

'''
输出 -> '慱 朗 手动 料理 机'
'''

上分点:词典(品牌)

由于 jieba 对品牌的分词效果不一定好,所以可以自行找一些品牌的词典来对这些标题或者 query 分词。

# 对整个数据集分词
def title_cut(x:str):
    return list(jieba.cut(x, HMM=True))

from joblib import Parallel, delayed

corpus_title = Parallel(n_jobs=4)(delayed(title_cut)(title) for title in corpus_data["title"])
train_title = Parallel(n_jobs=4)(delayed(title_cut)(title) for title in train_data["title"])
dev_title = Parallel(n_jobs=4)(delayed(title_cut)(title) for title in dev_data["title"])
# 使用 gensim 中的 Word2Vec 得到数据集的词向量
from gensim.models import Word2Vec
from gensim.test.utils import common_texts

if os.path.exists("word2vec.model"):
    model = Word2Vec.load("word2vec.model")
else: 
    model = Word2Vec(
        sentences=list(corpus_title) + list(train_title) + list(dev_title),
        vector_size=128, # 赛题需要提交的维度
        window=5,
        min_count=1,
        workers=4,
    )
    model.save("word2vec.model")

0x02 初探词向量

经过上面的步骤,我们已经获得了数据集中所有词的词向量。

计算与 格力 相似的 Top10 单词

下面可以先来看一下一个词的词向量,比如说“格力”:

model.wv["格力"], model.wv["格力"].shape
|500

可以看到,输出就是一个 128 维的连续向量。

再来找一下与格力相似的 Top 10 单词:

model.wv.most_similar("格力")

'''
输出:
[('奥克斯', 0.8410876989364624),
 ('GREE', 0.8224707245826721),
 ('柜机', 0.8208969235420227),
 ('海尔', 0.8145878911018372),
 ('美的', 0.8133372664451599),
 ('变频空调', 0.8063334226608276),
 ('1p1.5', 0.790867269039154),
 ('挂机', 0.7852355241775513),
 ('中央空调', 0.7766402959823608),
 ('新飞', 0.7650886178016663)]
'''

获取词向量的 Index

这是一种 NLP 建模中比较常用的手段,可以得到每个词向量的 ID。因为将这些词(字符串)映射为 ID,由 ID 来进行操作会方便一些。也可以不进行这一步操作。

# 通过 index 看一下词向量的前十条是什么
model.wv.index_to_key[0:10]
'''
输出:
[' ', '新款', '女', '/', '2021', '-', '加厚', '儿童', '秋冬', '外套']
'''

# 找一下“女”这个词的 index
model.wv.key_to_index["女"]
'''
输出:
2
'''
# 可以发现跟之前的是对应的

既然 index 可以索引到词,那么我们再来验证一下:

# 看一下“格力”的 index
model.wv.key_to_index["格力"]
'''
输出:
5024
'''

# 输入“格力”的 index,看看与之前打印的“格力”的词向量是否一样
model.wv[5024], model.wv[5024].shape
# 结果是一样的,可以自行测试

获取数据集的 index

# 从单词 -> id
# 我 爱 阿水
# 2 230 50
train_w2v_ids = [[model.wv.key_to_index[xx] for xx in x] for x in train_title]
corpus_w2v_ids = [[model.wv.key_to_index[xx] for xx in x] for x in corpus_title]
dev_w2v_ids = [[model.wv.key_to_index[xx] for xx in x] for x in dev_title]

0x03 使用词向量完成句子编码

TF-IDF

先使用 TF-IDF 计算一下词的重要性(区分力),识别 query 与 doc 中哪些词是不重要的。

from sklearn.feature_extraction.text import TfidfVectorizer

idf = TfidfVectorizer(analyzer=lambda x: x)
idf.fit(train_title + corpus_title)
idf.idf_, len(idf.idf_)
'''
输出:
(array([ 2.46292242,  8.5771301 ,  7.7050655 , ..., 14.21903717,
        14.21903717, 14.21903717]),
 640554)
'''

可以看见已经计算出了每个词的 TF-IDF。

token = np.array(idf.get_feature_names())

# 选出需要去除的词
# 分数为什么设置小于 10,来自水哥的先验经验
drop_token = token[np.where(idf.idf_ < 10)[0]]  # 统计分数小于 10 的词
drop_token = list(set(drop_token))
drop_token += ['领券']

# 得到不重要的单词的 index,以便后面过滤
drop_token_ids = [model.wv.key_to_index[x] for x in drop_token]

句子编码

def unsuper_w2c_encoding(s, pooling="max"):
    feat = []
    corpus_query_word = [x for x in s if x not in drop_token_ids]  # 将上一步中得到的不重要的(没有区分力的)词过滤
    if len(corpus_query_word) == 0:
        return np.zeros(128)
    
    # 获取句子的词向量
    # N * 128 的矩阵
    # N 是每条句子中筛去不重要的词,剩下的词的数量
    # 这一步得到的是一个矩阵,当我们最终需要的是一个句向量
    # 所以下一步就是把矩阵处理成向量
    feat = model.wv[corpus_query_word]

    # 通过 pooling 得到句子的词向量
    # 通过池化将矩阵降维到一个 128 维向量
    if pooling == "max":
        return np.array(feat).max(0)  # 对每一列取最大值,即最大池化
    if pooling == "avg":
        return np.array(feat).mean(0)  # 对每一列取均值,即均值池化
# 用第一条数据测试一下
unsuper_w2c_encoding(train_w2v_ids[0]).shape

(128,)

可以看到已经得到了 128 维的句向量。

那么对整个数据集进行句向量编码:

from tqdm import tqdm_notebook
# [corpus_w2v_ids[x] for x in qrels['doc'].values[:100] - 1]

corpus_mean_feat = [
    unsuper_w2c_encoding(s) for s in tqdm_notebook(corpus_w2v_ids[:1000])
]
corpus_mean_feat = np.vstack(corpus_mean_feat)

train_mean_feat = [
    unsuper_w2c_encoding(s) for s in tqdm_notebook(train_w2v_ids[:100])
]
train_mean_feat = np.vstack(train_mean_feat)

dev_mean_feat = [
    unsuper_w2c_encoding(s) for s in tqdm_notebook(dev_w2v_ids[:100])
]
dev_mean_feat = np.vstack(dev_mean_feat)

这里只用 corpus 的前 1000 条,traindev 的前 100 条作为演示。对整个数据集编码的话,去掉切片即可。

0x04 检索

既然已经计算得到的词向量,那么就可以用词向量来计算 train.query.txtcorpus.tsv 文本的相似度,然后再做一个相似度排序就可以实现检索。

from sklearn.preprocessing import normalize

# 归一化
corpus_mean_feat = normalize(corpus_mean_feat)
train_mean_feat = normalize(train_mean_feat)
dev_mean_feat = normalize(dev_mean_feat)

# 计算 Loss
mrr = []
for idx in tqdm_notebook(range(1, 10)):
    # 首先计算 train 与 corpus 的相似度,然后排序
    dis = np.dot(train_mean_feat[idx - 1], corpus_mean_feat.T)
    #print(dis)
    ids = np.argsort(dis)[::1]
    #print(ids)
    
    print(train_title[idx-1], corpus_data.loc[qrels.loc[idx].ravel()[0]]["title"],  dis[qrels.loc[idx].ravel()-1])
    print(corpus_title[ids[0]])

    # 计算每个检索的 MRR Loss(赛题中评价指标是用的 MRR)
    mrr.append(1/(np.where(ids == qrels.loc[idx].ravel()[0] - 1)[0][0] + 1))
    print('')

# 打印平均 MRR
print(np.mean(mrr))

`['美赞臣', '亲舒', '一段'] 领券满减】美赞臣安婴儿A+亲舒婴儿奶粉1段850克 0-12个月宝宝 [0.68474327] ['现货', '加拿大', '美赞臣', '1', '段', 'EnfamilA', '+', '一段', 'DHA', '奶粉', '765', '克', '超值', '装金装']

['慱', '朗', '手动', '料理', '机'] Braun/博朗 MQ3035/3000/5025料理棒手持小型婴儿辅食家用搅拌机 [0.71025692] ['朗', '诗', 'LS22A1203', '时尚', '气质', '休闲', '百搭显', '瘦', '拼色', '假', '两件', '衬衣', '小衫', '2022', '春装']

['電力貓'] 小米WiFi电力猫无线路由器套装一对300M穿墙宝家用信号增强扩展器 [0.03024337] ['[', '新华书店', ']', ' ', '党委会', '的', '工作', '方法', ' ', '毛']`

MRR = 0.01528688737011513

这里只展示部分检索结果,可以看到有些j检索结果并不好,有些品牌的分词也不太对。所以后面还可以考虑去掉一些停用词或者标点符号之类的数据清洗手段,达到更好的效果。毕竟数据为王,数据科学中最重要,最耗时的一步其实就是处理数据的工作。

最后将词向量存储到本地:

with open('query_embedding', 'w') as up :
    for id, feat in zip(dev_data.index, dev_mean_feat):
        up.write('{0}\t{1}\n'.format(id, ','.join([str(x)[:6] for x in feat])))
        
with open('doc_embedding', 'w') as up :
    for id, feat in zip(corpus_data.index, corpus_mean_feat):
        up.write('{0}\t{1}\n'.format(id, ','.join([str(x)[:6] for x in feat])))

Conclusion

水哥讲的太好了,对我这种刚入门 NLP 的小白帮助很大,学到了 NLP 完整的一套训练流程。

感觉句子编码 pooling 的时候好像也可以做一些技巧。目前是直接用 Word2Vec 得到的词向量,这个比赛本质上是看谁的词向量训练的好,所以后面尝试用 SimCSE 这些深度模型来训练,效果应该会提升一些。当然了,提分的话首先还是从数据入手,比如 corpustitle 长度很长,会有一百多个字符,而 query 就是比较短的搜索。所以 corpus 里的那些非核心词(噪声)势必会对训练产生影响。

References

team-learning-data-mining/ECommerceSearch at master · datawhalechina/team-learning-data-mining · GitHub

天池大神阿水解读:阿里灵杰电商搜索算法赛 | bilibili