说明
简单就是美
说起来这个项目很早之前做过,最近用到,再梳理一次。
这篇文章草稿是在2021年的,现在是2024年,继续写完它。
内容
1 TF-IDF
来自百度的解释:TF-IDF是一种统计方法,用以评估一字词对于一个文件集或一个语料库中的其中一份文件的重要程度。字词的重要性随着它在文件中出现的次数成正比增加,但同时会随着它在语料库中出现的频率成反比下降。
概念:
- 1 语料 corpus: 或者说文件集
- 2 文档 document: 或者说一篇文章
- 3 词频 TF(Term Frequency):TF表示词条在文档d中出现的频率。
- 4 逆词频IDF(Inverse Document Frequency):如果包含词条t的文档越少,也就是n越小,IDF越大,则说明词条t具有很好的类别区分能力。
关于TF-IDF,知乎的这篇文章还不错
当有TF(词频)和IDF(逆文档频率)后,将这两个词相乘,就能得到一个词的TF-IDF的值。某个词在文章中的TF-IDF越大,那么一般而言这个词在这篇文章的重要性会越高,所以通过计算文章中各个词的TF-IDF,由大到小排序,排在最前面的几个词,就是该文章的关键词。
一些拓展是word2vec, 很早就有打算看,但现在仍然没时间看(不过原来曾经用SAS搞过一下n-grams)。也mark一下吧:
word2vec是浅层神经网络模型,有两种网络结构,分别为CBOW和skip-gram。word2vec是浅层神经网络模型,有两种网络结构,分别为CBOW和skip-gram。
# 旧文档
doc1 = list('ambulancewithpolicecars')
doc2 = list('applebananacherry')
doc3 = list('benzaudivolkswagan')
corpus = [doc1, doc2, doc3]
c_list1 = [1,2,3]
array([list(['a', 'm', 'b', 'u', 'l', 'a', 'n', 'c', 'e', 'w', 'i', 't', 'h', 'p', 'o', 'l', 'i', 'c', 'e', 'c', 'a', 'r', 's']),
list(['a', 'p', 'p', 'l', 'e', 'b', 'a', 'n', 'a', 'n', 'a', 'c', 'h', 'e', 'r', 'r', 'y']),
list(['b', 'e', 'n', 'z', 'a', 'u', 'd', 'i', 'v', 'o', 'l', 'k', 's', 'w', 'a', 'g', 'a', 'n'])]
# 新文档
new_doc1 = list('hpdelllenovo')
new_doc2 = list('friendslove')
new_corpus = [new_doc1, new_doc2]
c_list2 = [1, 2]
# 原始idf主字典
main_idf_dict = {}
from collections import Counter
from itertools import chain
import pandas as pd
import numpy as np
import copy
corpus_s = pd.Series(corpus)
# TF calculation
corpus_s1 = corpus_s.apply(lambda x: dict(Counter(x)))
doc_words = corpus_s.apply(len)
In [11]: corpus_s1
Out[11]:
0 {'a': 3, 'm': 1, 'b': 1, 'u': 1, 'l': 2, 'n': ...
1 {'a': 4, 'p': 2, 'l': 1, 'e': 2, 'b': 1, 'n': ...
2 {'b': 1, 'e': 1, 'n': 2, 'z': 1, 'a': 3, 'u': ...
In [10]: doc_words
Out[10]:
0 23
1 17
2 18
dtype: int64
# IDF calculation
corpus_s2 = corpus_s1.apply(lambda x: list(x.keys()))
idf_dict = Counter(chain.from_iterable(corpus_s2))
In [13]: idf_dict
Out[13]:
Counter({'a': 3,
'm': 1,
'b': 3,
'u': 2,
'l': 3,
'n': 3,
'c': 2,
'e': 3,
'w': 2,
'i': 2,
't': 1,
'h': 2,
'p': 2,
'o': 2,
'r': 2,
's': 2,
'y': 1,
'z': 1,
'd': 1,
'v': 1,
'k': 1,
'g': 1})
# 原始idf主字典
main_idf_dict = {}
s1 = pd.Series(main_idf_dict)
s2 = pd.Series(idf_dict)
s3 = s1+s2
add_key = dict(s3.dropna())
main_idf_dict.update(idf_dict)
main_idf_dict.update(add_key)
mod_keys = list(idf_dict.keys()) # 可以并行
In [16]: main_idf_dict
Out[16]:
{'a': 3,
'm': 1,
'b': 3,
'u': 2,
'l': 3,
'n': 3,
'c': 2,
'e': 3,
'w': 2,
'i': 2,
't': 1,
'h': 2,
'p': 2,
'o': 2,
'r': 2,
's': 2,
'y': 1,
'z': 1,
'd': 1,
'v': 1,
'k': 1,
'g': 1}
In [17]: mod_keys
Out[17]:
['a',
'm',
'b',
'u',
'l',
'n',
'c',
'e',
'w',
'i',
't',
'h',
'p',
'o',
'r',
's',
'y',
'z',
'd',
'v',
'k',
'g']
- 1 首先有新老的文档集 list of character list
- 2 将文档转为Series,这样后续的计算就是by 文档进行并行计算的
- 3 然后就可以用apply方法并行的计算词频(TF)和逆文档频率(IDF),现在注意力放在IDF上
- 4 corpus_s1已经获得了文档的词频统计,corpus_s2则取corpus_s1字典的键作为统计元素,这样无论某个词出现了几次,当作为键被统计时,只是1次
- 5 通过对list of set 的Counter X iterable, 实现了快速的频数统计(几乎也是并行的)
- 6 此时对main_idf_dict(最初为空)操作,变为Series,最初的s1是空序列
- 7 然后将此时的新增idf_dict进行转序列,变为s2
- 8 s1+s2, 由于一个列表为空,加出的s3也全部为空,因为没有相同的对齐索引
- 9 如果有相同的元素,那么add_key 就是相加后的结果(s3.dropna()剩下的必然是相同,被相加的元素)
- 10 接下来的
main_idf_dict.update(idf_dict)
会将当前的字典覆盖上,但是那些相加的部分也被覆盖掉了 - 11 然后再用
main_idf_dict.update(add_key)
将相加的部分覆盖掉,此时就是正常的结果了 - 12 最后mod_keys是本次更改涉及的所有键。
总计上这么写也是ok的,虽然有些无效操作(覆盖2次)。毕竟这个代码写在好几年前,如果考虑到数据库的话,其实可以根据每次更新的idf_dict中的key进行数据查找,更改好之后直接update回去就可以了。现在这种写法更偏向离线操作和持久化较多的情况。
上面操作后,初始的main_idf_dict
产生了变化
In [23]: main_idf_dict
Out[23]:
{'a': 3,
'm': 1,
'b': 3,
'u': 2,
'l': 3,
'n': 3,
'c': 2,
'e': 3,
'w': 2,
'i': 2,
't': 1,
'h': 2,
'p': 2,
'o': 2,
'r': 2,
's': 2,
'y': 1,
'z': 1,
'd': 1,
'v': 1,
'k': 1,
'g': 1}
2 增量IDF
TF-IDF的实现并不难,但是当面对特别巨大的语料,或者需要持续不断的更新时,就需要改变一下实现的方式了。
当算法可以从内存搬到硬盘,允许增量的计算时,还是很有意思的
值得注意的是,TF-IDF应该针对的是较长的文本端,主题内容能够通过分词体现出来。一篇文章的TF总是容易计算,关键还是IDF的增量计算。
接着上面的代码继续,假设这时候来了新的文档(new_corpus),同样执行计算,此时可以封装一下
import pandas as pd
import numpy as np
from collections import Counter
from itertools import chain
def get_idf_dict(corpus = None):
corpus_s = pd.Series(corpus)
# TF calculation
corpus_s1 = corpus_s.apply(lambda x: dict(Counter(x)))
doc_words = corpus_s.apply(len)
# IDF calculation
corpus_s2 = corpus_s1.apply(lambda x: list(x.keys()))
idf_dict = Counter(chain.from_iterable(corpus_s2))
return idf_dict
new_idf_dict = get_idf_dict(new_corpus)
Counter({'h': 1,
'p': 1,
'd': 2,
'e': 2,
'l': 2,
'n': 2,
'o': 2,
'v': 2,
'f': 1,
'r': 1,
'i': 1,
's': 1})
然后将这部分增量的idf叠加上
import pandas as pd
def increadd_dict(master_dict = None, slave_dict = None):
s1 = pd.Series(master_dict)
s2 = pd.Series(slave_dict)
s3 = s1+s2
add_key = dict(s3.dropna())
master_dict.update(slave_dict)
master_dict.update(add_key)
mod_keys = list(s2.keys()) # 可以并行
return master_dict, mod_keys
所以更新后的字典
main_idf_dict, mod_keys = increadd_dict(main_idf_dict,new_idf_dict)
改变的键值:['h', 'p', 'd', 'e', 'l', 'n', 'o', 'v', 'f', 'r', 'i', 's']
{'a': 3,
'm': 1,
'b': 3,
'u': 2,
'l': 5.0,
'n': 5.0,
'c': 2,
'e': 5.0,
'w': 2,
'i': 3.0,
't': 1,
'h': 3.0,
'p': 3.0,
'o': 4.0,
'r': 3.0,
's': 3.0,
'y': 1,
'z': 1,
'd': 3.0,
'v': 3.0,
'k': 1,
'g': 1,
'f': 1}
以上实现了增加新文档时,IDF词频的统计,是最麻烦的部分。
3 完整的IDF
IDF是学习的过程
正如机器学习分为train和predict两部分一样,统计idf的过程本身是一种学习过程。每次接纳新的数据,模型了解的统计分布应当是越全面的。但是为了避免简单的重复,我们必须要记下学过哪些文档。同时,IDF恰好也需要所有的文档数作为分子,一举两得。
我们假定,corpus里的每一行,也就是每一篇文章,会有一个唯一的id。这个id可以是人为的编号,也可以是内容的md5 id。
每一次的学习,对应于一次语料的增量的学习。每一个idf模型,是由其训练的语料集合唯一确定的。为统一期间,我们把corpus较为dataset,这样就可以和一般的规范接上了。
这个简单的模型由 dataset, pid_list(文章编号)唯一决定。当进行增量更新时,得到的结果是新模型。当然新模型是一部分新数据在老数据上叠加的结果,所以是增量。很像大模型的微调
所以在每一次训练时,需要传入的是 zip(pid_list, data_list),如果从离线状态来理解,那么idf模型会保留一个pid_set和idf_dict。其中pid_set既提供了文档总数,同时能过滤掉重复的数据。
初始化状态
idf_model = {'pid_set': set([]), 'idf_dict' : {}}
def idf_train(idf_model = None, data_list = None, pid_list = None):
pid_set = idf_model['pid_set']
idf_dict = idf_model['idf_dict']
gap_set = set(pid_list) - set(pid_set)
if len(gap_set):
print('updating %s recs ' % len(gap_set))
else:
print('No UPDATING')
filter_data_list = []
for i, v in enumerate(pid_list):
if v in gap_set:
filter_data_list.append(data_list[i])
new_idf_dict = get_idf_dict(filter_data_list)
_idf_dict,mod_keys = increadd_dict(idf_dict, new_idf_dict)
idf_model['idf_dict'] = _idf_dict
idf_model['pid_set'] = pid_set | gap_set
return idf_model
可以看到,第二次训练没有更新,因为新的pid_list = [1,2]是和已有的id重复的。
idf_model1 = idf_train(idf_model = idf_model, data_list= corpus, pid_list= c_list1)
updating 3 recs
idf_model2 = idf_train(idf_model = idf_model, data_list= new_corpus, pid_list= c_list2)
No UPDATING
4 小结
写到这里差不多了,解决了最核心的问题(计算增量idf)。词频随着新数据而更新,而文档总数在锁定idf_model,计算集合长度就可以了。
就TFIDF本身的计算而言,还有TF的归一化计算(实际上在计算IDF时,这部分功能已经实现了)。
Next:
- 1 继续完成整个TF-IDF的功能
- 2 将TF-IDF进行对象化,在idf_model函数时已经看出来,应该对象化。
- 3 将训练过程规范化,每次的训练数据按规范入库,并锁定每次增量训练的dataset和模型)
- 4 将结果封装为服务,随时可以调用不同的idf模型
- 5 完成前端的展示。