1.2 TF-IDF 实践

0x00 Abstract

TF-IDF 是 NLP 入门的基础知识。通过对这种编码方式的学习,可以使我们更加容易理解 NLP 工作的本质。这篇笔记重点在实践 TF-IDF。想了解更多相关理论可以参考 矢量语义与嵌入之 TF-IDF 检索

0x01 Count Vector

  • 词袋模型 Bow
    • 目的:最基础的文本特征提取方法,将文本转为计数矩阵
    • 原理:类似 One-Hot
    • 优点:
      • 简单、直接
      • 易于理解
    • 不足:
      • 稀疏
      • 相当于只统计了词频
        • 但是像“你我他,的地得”这种词,每个文章中都会出现
        • 所以需要如何把这种词的权重降下来?

实践

  1. 手撸 Count Vector
texts = ["He is a boy", "She is a girl, good girl"]

word_set = set()

# 存储词典
for text in texts:
    for word in text.strip().split(' '):
        word_set.add(word.strip(','))

print(word_set)
# 输出:
# {'He', 'girl', 'is', 'good', 'She', 'a', 'boy'}

# 给词典中所有词语赋一个 id 值
word_id_dict = {}
for word in enumerate(word_set):
    word_id_dict[word[1]] = word[0]

print(word_id_dict)
# 输出:
# {'He': 0, 'girl': 1, 'is': 2, 'good': 3, 'She': 4, 'a': 5, 'boy': 6}

# 根据上一步生成的 word_id_dict,将文档 texts 中的词语替换成对应的 id
res_list = []

for text in texts:
    t_list = []
    for word in text.strip().split(' '):
        word = word.strip(',')
        if word in word_id_dict:
            t_list.append(word_id_dict[word])
    res_list.append(t_list)

print(res_list)
# 输出:
# [[0, 2, 5, 6], [4, 2, 5, 1, 3, 1]]

# 将文档 texts 转为向量
# tests 中有两个文档,每个文档的向量长度为词典长度
# 根据 word_id_dict,出现的词语在 id 对应的列表位置 +1
for res in res_list:
    result = [0] * len(word_set)
    for word_id in res:
        result[word_id] += 1

    print(result)
# 输出:
# [1, 0, 1, 0, 0, 1, 1]
# [0, 2, 1, 1, 1, 1, 0]
  1. 调用 Sklearn sklearn——CountVectorizer 详解

总结

Count Vector 方法只考虑了 TF,很片面!由此引出 TF-IDF(两者相乘)方法。

0x02 TF-IDF 的原理

  • 词频 TF :反应文章的 局部信息
    • 在一篇文章中,越重要的内容,出现(强调)的次数越多,那么词频 TF 就会越高。所以这些高词频的词,就可以代表这篇文章。
    • 但伴随而来的问题是,文章中许多的语气词或者“你我他”这种词或者标点符号,同样也会出现很多次,但这些词往往也是高频词,但是没有意义。如何解决这种情况?那就需要 IDF
  • 逆文本频率指数 IDF :反应系统的 全局信息
    • IDF 可以帮助我们判断词语在系统中的 区分力 大小。
      • 比如,如果 每篇文章 中都有“我”,那么它在所有文章中的 区分力都不强
      • 也就是说,在 所有文档 中都经常出现的词语,区分力小;而不常出现的词,区分力强。
  • TF-IDF : TF × IDF
    • 将两种指数相乘,得到 TF-IDF,表达一篇文档。
    • 降低没有意义的词的重要性,突出文章中真正具有关键意义的内容(词语)。
    • 对于一个 word,在文档出现的频率高,但在语料库里出现频率低,那么这个 word 对该文档的重要性比较高。
TF(词频 - Term Frequency):指的是某一个给定的词语在该文件中出现的次数,这个数字通常会被归一化(一般是词频除以文章总词数), 以防止它偏向长的文件。

IDF(逆文本频率指数 - Inverse Document Frequency):包含指定词语的文档越少,IDF越大。某个词语的IDF,由总文件数目除以包含该词语之文件的数目,再将得到的商取对数得到,即:
IDF=log(N/n)
-   N代表语料库中文档的总数
-   n代表某个word在几个文档中出现过;

1. 前置知识点:文本相似度

  • 余弦相似度——最常用的相似度计算方法
    • 一个向量空间中两个向量夹角的余弦值作为衡量两个个体之间差异的大小
    • 余弦值接近 1,夹角趋于 0,表明两个向量越相似!
  • Jaccard 相似度——用于比较有限样本集之间的相似性与差异性
    • Jaccard 系数定义为 A 与 B 交集的大小 同 A 与 B 并集的大小的比值
    • Jaccard Similarity 得分在 0 到 1 的范围内。如果两个文档相同,则 Jaccard Similarity 为 1。如果两个文档之间没有共同词,则 Jaccard 相似度得分为 0。
    • 当 A 和 B 都为空,J(A,B) = 1
  • 欧氏距离相似度——最常见的距离度量方法
    • n 维空间中计算两点间距离

适用场景: 比如向量 A(1,1,1),向量 B(5,5,5),两个向量虽然余弦相似度一致(夹角为 0),但是在空间上,两个向量并非一致。类比到用户 A 购买了三个 1 元的物品,用户 B 购买了三个 5 元的物品,衡量这两个用户的消费能力时,显然用余弦相似度是不合适的,欧氏距离更加合适。

欧氏距离能够体现个体数值特征的绝对差异,所以更多的用于需要从维度的数值大小中体现差异的分析,比如使用用户行为作为指标分析用户价值的相似情况(比较不同用户的消费能力),这属于价值度量;而余弦相似度对绝对数值不敏感,更多的用于使用用户对内容的评分来分析用户兴趣的相似程度(用户是否喜欢某商品),这属于定性度量。

需要注意的是,欧氏距离和余弦相似度都需要保证各维度处于相同的刻度级别(量纲),所以一般需要对数据先进行标准化处理,否则很可能会引起偏差。比如用户对内容评分,假设为 5 分制,对用户甲来说评分 3 分以上就是自己喜欢的,而对于用户乙,评分 4 分以上才是自己喜欢的,这样就无法很好地衡量两个用户评分之间的相似程度。如果将评分数值减去平均值,那么就可以很好地解决问题。此时,就相当于用皮尔逊相关系数来度量相似程度。

实践:计算两个字符串的相似度

首先生成文本的向量化表达:

import jieba
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np

texts = ['这只皮靴号码大了,那只号码合适','这只皮靴号码不小,那只更合适']

# 分词
texts_cut = []

def cut_word(sentence):
    return '/'.join(jieba.lcut(sentence))

for text in texts:
    #text = text.replace(',', '')
    texts_cut.append(cut_word(text))
    # strip() 只能移除字符串头尾指定的字符序列,不能删除中间部分的字符
print(texts_cut)
# 输出:分词结果
# ['这/只/皮靴/号码/大/了/,/那/只/号码/合适', '这/只/皮靴/号码/不小/,/那/只/更/合适']

# 创建词袋数据结构
# CountVectorizer 参数中,默认的正则表达式选择 2 个及以上的字母或数字作为 token
# 那么会导致分词为单个字的词语被忽略
# 所以这里需要修改正则表达式参数
cv = CountVectorizer(token_pattern=r"(?u)\b\w+\b")  # 修改正则表达式参数

cv_fit = cv.fit_transform(texts_cut)
# 上行代码等价于下面两行
# cv.fit(texts)
# cv_fit=cv.transform(texts)

# 列表形式呈现文章生成的词典
print(cv.get_feature_names())
# 输出:词典
# ['不小', '了', '只', '号码', '合适', '大', '更', '皮靴', '这', '那']

# 字典形式的词典,带有词语 id
print(cv.vocabulary_)
# 输出:
# {'这': 8, '只': 2, '皮靴': 7, '号码': 3, '大': 5, '了': 1, '那': 9, '合适': 4, '不小': 0, '更': 6}
# 字典形式,key:词,value:该词(特征)的索引

# 所有文本的词频
print(cv_fit)
# 输出:
# (0, 8)  1  第 0 个文档中,词典索引为 8 的元素(这),词频为 1
# (0, 2)  2
# (0, 7)  1
# (0, 3)  2
# (0, 5)  1
# (0, 1)  1
# (0, 9)  1
# (0, 4)  1
# (1, 8)  1
# (1, 2)  2
# (1, 7)  1
# (1, 3)  1
# (1, 9)  1
# (1, 4)  1
# (1, 0)  1
# (1, 6)  1

# toarray() 将结果转为稀疏矩阵的表达
print(cv_fit.toarray())
# 输出:
# [[0 1 2 2 1 1 0 1 1 1]
#  [1 0 2 1 1 0 1 1 1 1]]
  1. 使用余弦相似度计算
# 计算余弦相似度
texts_countvector = cv_fit.toarray()
cos_sim = cosine_similarity(texts_countvector[0].reshape(1,-1), texts_countvector[1].reshape(1,-1))

print('Cosine Similarity = %f'%cos_sim)
  1. 使用 Jaccard 相似度计算
# 计算 jaccard 相似度
def jaccard_sim(a,b):
    unions = len(set(a).union(set(b)))
    intersections = len(set(a).intersection(set(b)))
    return intersections / unions

print('Jaccard Similarity = %f'%jaccard_sim(texts_cut[0],texts_cut[1]))
  1. 使用欧氏距离相似度计算
# 计算欧氏距离相似度
# calculating Euclidean distance
# using linalg.norm()
point1 = texts_countvector[0]
point2 = texts_countvector[1]
dist = np.linalg.norm(point1 - point2)

print('Euclidean Distance = %f'%dist)

2. TF 标准计算公式

上一节中计算相似度使用的向量化方式其实就是 TF。一开始学习的 Count Vector 其实也是 TF,只是没有做归一化。

3. IDF 标准计算公式

分母加 1 为了防止式子除 0。 对整个式子取 log,为了防止小数过长。

4. TF-IDF 总结

  • TF-IDF
    • 目的
    • 原理:TF × IDF
      • 特点:稀疏
    • 优点
      • 考虑了词的重要性
      • 简单快速,而且容易理解
    • 缺点
      • 稀疏——计算、存储效率
      • 每个词之间相互独立
        • 只考虑词频信息,比较片面
        • 不考虑词语的有序性(位置信息)
        • 不能衡量词之间的相似度

0x03 TF-IDF 的实践

import math
import pandas as pd

docA = "The cat sat on my face"
docB = "The dog sat on my bed"

bowA = docA.split(' ')
bowB = docB.split(' ')

wordSet = set(bowA).union(set(bowB))

wordDictA = dict.fromkeys(wordSet, 0)
wordDictB = dict.fromkeys(wordSet, 0)

for word in bowA:
    wordDictA[word] += 1

for word in bowB:
    wordDictB[word] += 1

print(pd.DataFrame([wordDictA, wordDictB]))
# 输出:文本向量化
#    my  The  cat  on  bed  dog  sat  face
# 0   1    1    1   1    0    0    1     1
# 1   1    1    0   1    1    1    1     0

def computeTF(wordDict, bow):
    tfDict = {}
    bowCount = len(bow)
    for word, count in wordDict.items():
        tfDict[word] = count / float(bowCount)

    return tfDict

tfDictA = computeTF(wordDictA, bowA)
tfDictB = computeTF(wordDictB, bowB)

# IDF = log(语料库的文档总数 / (包含该词的文档数 + 1))
def computeIDF(docList):
    idfDict = {}
    N = len(docList)

    idfDict = dict.fromkeys(docList[0].keys(), 0)
    for doc in docList:
        for word, val in doc.items():
            if val > 0:
                idfDict[word] += 1
    print('N:', N)
    print(idfDict)

    for word, val in idfDict.items():
        idfDict[word] = math.log10(N /( float(val)+1))

    return idfDict

idfs = computeIDF([wordDictA, wordDictB])
print(idfs)
# 输出:
# N: 2(总文档数为 2)
# 统计每个词在所有文档中出现的次数:
# {'my': 2, 'The': 2, 'cat': 1, 'on': 2, 'bed': 1, 'dog': 1, 'sat': 2, 'face': 1}
# 计算得到的 idf:
# {'my': -0.17609125905568127, 'The': -0.17609125905568127, 'cat': 0.0, 'on': -0.17609125905568127, 'bed': 0.0, 'dog': 0.0, 'sat': -0.17609125905568127, 'face': 0.0}

# 注意!!! 为什么有的 idf 是 0 ?
# 因为只出现一次的词语通过计算得到 log(2/1+1) = 0

def computeTFIDF(tfDict, idfs):
    tfidfDict = {}
    for word, val in tfDict.items():
        tfidfDict[word] = val * idfs[word]

    return tfidfDict

tfidfDictA = computeTFIDF(tfDictA, idfs)
tfidfDictB = computeTFIDF(tfDictB, idfs)

print(pd.DataFrame([tfidfDictA, tfidfDictB]))
# 输出:所有文档的 TF-IDF
#          my       The  cat        on  bed  dog       sat  face
# 0 -0.029349 -0.029349  0.0 -0.029349  0.0  0.0 -0.029349   0.0
# 1 -0.029349 -0.029349  0.0 -0.029349  0.0  0.0 -0.029349   0.0

0x04 项目1:《文章关键信息提取》

目标:对一个数据集中的所有文章做关键信息提取。

首先将 input_tfidf_dir 中的所有文章整个到一个文件里。

# -----------
# convert.py
# -----------

import os
import sys

# 「argv」是「argument variable」参数变量的简写形式,一般在命令行调用的时候由系统传递给程序。这个变量其实是一个List列表,argv[0] 一般是“被调用的脚本文件名或全路径”,这个与操作系统有关,argv[1]和以后就是传入的系统命令参数。
file_path_dir = sys.argv[1]

def file_handler(file_path):
    f = open(file_path, 'r')
    return f

file_name = 0
for f in os.listdir(file_path_dir):
    if not f.startswith('.'):  # 筛除 以 “.” 开头的隐藏文件
        file_path = file_path_dir + "/" + f
        content_list = []

        file = file_handler(file_path)
        for line in file:
            content_list.append(line.strip())

        print('\t'.join([str(file_name), ' '.join(content_list)]))

        file_name += 1
# 查看一下输出
python convert.py input_tfidf_dir
# 将输出存入文件
python convert.py input_tfidf_dir > tfidf_input.data

MapReduce 要做下面的事情:

将左边乱序的输出排序,相同的 word 放一起。执行 reduce.py 的时候,要考虑到如右图——最后一个 word 只有一行的情况。

将文档里出现的词打印出来。

# -----------
# map.py
# -----------

import sys

for line in sys.stdin:
    ss = line.strip().split('\t')
    if len(ss) != 2:  # 检查
        continue

    file_name, file_content = ss
    word_list = file_content.strip().split(' ')

    word_set = set(word_list)  # 去除一篇文档中重复的单词

    for word in word_set:
        print('\t'.join([word, '1']))

检验一下 map.py 文件做了什么,可以做什么。

# 输出每个文档中出行啊的词
cat tfidf_input.data | python map.py

# 打印输出的结果
cat tfidf_input.data | python map.py | grep '设计'

# 统计个数
cat tfidf_input.data | python map.py | grep -c '设计'

# 将输出结果做个排序
 cat tfidf_input.data | python map.py | sort -k1 -nr

计算 idf。

# -----------
# reduce.py
# -----------

import sys
import math

docs_cnt = 508

current_word = None
count = 0

for line in sys.stdin:
    ss = line.strip().split('\t')
    if len(ss) != 2:

        continue
    word, val = ss
    if current_word == None:
        current_word = word

    if current_word != word:
        idf = math.log10(docs_cnt / (float(count) + 1.0))  # 计算 idf
        print('\t'.join([current_word, str(idf)]))
        count = 0  # 重置 count
        current_word = word  # 换到下一个 word

    count += int(val)

# 防止最后一类只有一行,导致最后一类没有输出
idf = math.log10(docs_cnt / (float(count) + 1.0))
print('\t'.join([current_word, str(idf)]))
# 将计算的 idf 存储到 result_idf.data 文件里
cat tfidf_input.data | python map.py | sort -k1 -nr | python reduce.py > result_idf.data

# 查看一下 “的” 的 idf
cat result_idf.data | grep '的'
# 输出:
# 的  0.0008557529505832833

0x05 项目2:文本相似度计算

GitHub - cjymz886/sentence-similarity: 对四种句子/文本相似度计算方法进行实验与比较

使用 Word2Vec 的方法将所有句子生成句向量,然后利用四种方法计算文本相似度。

References

配置 Vim: 怎样在 Ubuntu 上安装最新的 Vim | 月灯依旧 保姆级教程!将 Vim 打造一个 IDE (Python 篇) Vim-Python 环境 - 知乎 Vim - 配置 IDE 一般的 python 环境 - 知乎 Vim 插件 YouCompleteMe 安装归纳

文本相似度: 推荐算法原理(二)欧几里得距离计算物品间相似度 - 知乎 相似度度量:欧氏距离与余弦相似度 sklearn中CountVectorizer里token_pattern默认参数解读

相似度计算: Python 中的余弦相似度 | D栈 - Delft Stack GitHub - cjymz886/sentence-similarity: 对四种句子/文本相似度计算方法进行实验与比较