DOIFOR技术基于TF-IDF的文档关键字提取实现
DOIFOR技术基于TF-IDF的文档关键字提取实现

基于TF-IDF的文档关键字提取实现

技术

什么是TF-IDF

TF-IDF(Term Frequency-Inverse Document Frequency, 词频-逆文件频率).

是一种用于资讯检索与资讯探勘的常用加权技术。TF-IDF是一种统计方法,用以评估一字词对于一个文件集或一个语料库中的其中一份文件的重要程度。字词的重要性随着它在文件中出现的次数成正比增加,但同时会随着它在语料库中出现的频率成反比下降。

上述引用总结就是, 一个词语在一篇文章中出现次数越多, 同时在所有文档中出现次数越少, 越能够代表该文章.

这也就是TF-IDF的含义.

词频 (term frequency, TF)

指的是某一个给定的词语在该文件中出现的次数。这个数字通常会被归一化(一般是词频除以文章总词数), 以防止它偏向长的文件。(同一个词语在长文件里可能会比短文件有更高的词频,而不管该词语重要与否。)

但是, 需要注意, 一些通用的词语对于主题并没有太大的作用, 反倒是一些出现频率较少的词才能够表达文章的主题, 所以单纯使用是TF不合适的。权重的设计必须满足:一个词预测主题的能力越强,权重越大,反之,权重越小。所有统计的文章中,一些词只是在其中很少几篇文章中出现,那么这样的词对文章的主题的作用很大,这些词的权重应该设计的较大。IDF就是在完成这样的工作.

公式:

TF(w)=\frac{在某一类中词条w出现的次数}{该类中所有的词条数目}

逆向文件频率 (inverse document frequency, IDF)

IDF的主要思想是:如果包含词条t的文档越少, IDF越大,则说明词条具有很好的类别区分能力。某一特定词语的IDF,可以由总文件数目除以包含该词语之文件的数目,再将得到的商取对数得到。

公式:

IDF(w)=\log(\frac{语料库的文档总数}{包含词条w的文档数+1}) 

分母之所以要加1,是为了避免分母为0

某一特定文件内的高词语频率,以及该词语在整个文件集合中的低文件频率,可以产生出高权重的TF-IDF。因此,TF-IDF倾向于过滤掉常见的词语,保留重要的词语。
  

TF−IDF(w)=TF(w) \times IDF(w) 

参见 TF-IDF原理及使用
参见 TF-IDF与余弦相似性的应用(一):自动提取关键词

实践

关键词提取的一个关键就是中文分词,网上看了很多关于中文词的比较,最终选择了清华大学出品:thulac来进行分词。

分词(thulac4j)

thulac4j是THULAC的高效Java 8实现,具有分词速度快、准、强的特点;提供中文分词、词性标注功能,支持:

  • 自定义词典
  • 繁体转简体
  • 停用词过滤

实现代码:

    // 初始化分词工具
        POSTagger posTagger = new POSTagger("/model_c_model.bin","/model_c_dat.bin");

    // 有些文本比较大,不方便一次性读取到内存中,所以直接使用流来获取句子。
        try (BufferedReader reader = new BufferedReader(new FileReader("D:/8.txt"))) {
            List tokenItems = reader.lines()
                    .map(posTagger::tokenize)
                    .flatMap(Collection::stream)
            // 过滤掉单字符词条,这类词条几乎不会成为关键词
                    .filter(tokenItem -> tokenItem.word.length() > 1)
                    .collect(Collectors.toList());

        // 保存文本中词条总数
            final int totalWord = tokenItems.size();

        // 词频计算
            Map tfMap = tokenItems.stream()
                    .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()))
                    .entrySet().stream()
                    .collect(Collectors.toMap(Map.Entry::getKey, en -> en.getValue() * 1.0 / totalWord));
            for (Map.Entry tokenItemDoubleEntry : tfMap.entrySet()) {
                System.out.println(tokenItemDoubleEntry.getKey()+"\t"+tokenItemDoubleEntry.getValue());
            }
        }

代码中使用了thulac提供的分词模型,该模型需要去thulac官网下载。

以上代码执行结果如下:

灵活/a    9.130752373995617E-5
昨晚/t    1.8261504747991235E-4
高兴/a    1.8261504747991235E-4
最终/d    8.217677136596055E-4
嘴里/s    9.130752373995617E-5
技能/n    9.130752373995617E-5
......

缓存IDF

在实践中语料库非常的大,如果需要实时的计算TF-IDF值时,如何快速获取IDF值就是一个急需解决的问题了。为此咱们可以预先计算各词的IDF值,并进行缓存以便在快速计算关键字的时候。

观察IDF公司可以看出主要有两个量:语料库文档总数、包含某词条的文档数。都为整形数据,而且每次解析完一篇文章后,几乎都是直接自增一次。因此,直接使用redis来做缓存,缓存一系列的long类型的value,使用redis的incr来指令来完成自增。

直接贴代码:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Set;

/**
 * 语料库的IDF缓存服务
 */
@Service
public class IDFCacheService {

    /**
     * 基于spring-data-redis的redis客户端
     */
    @Autowired private RedisTemplate redisTemplate;

    /**
     * 语料库已分析的文档计数器KEY:{@value}
     */
    private static final String TOTAL_DOCUMENT = "total_document_number";

    /**
     * 已分析文档数计数器自增
     */
    public void documentNumberIncrement(){
        wordNumberIncrement(TOTAL_DOCUMENT);
    }

    /**
     * @return 返回当前已分析的文档数
     */
    public long documentNumber(){
        return wordNumber(TOTAL_DOCUMENT);
    }

    /**
     * 变更一组词条的计数器
     * @param words 待更新词条集合
     */
    public void wordNumberIncrement(Set words) {
        words.forEach(this::wordNumberIncrement);
    }

    /**
     * 更新一个词的计数器,使其自增+1
     * @param word 目标词条
     */
    public void wordNumberIncrement(String word){
        redisTemplate.opsForValue().increment(word);
    }

    /**
     * 返回包含该词条的文档数
     * @param word 目标词条
     * @return 文档数
     */
    public long wordNumber(String word) {
        Long aLong = (Long) redisTemplate.opsForValue().get(word);
        return null == aLong ? 0L : aLong;
    }

    /**
     * 返回一个词条的IDF
     * 由于语料库在不断的更新,IDF计算也在不断的进行,所以这个值也是不断在变化的,实时计算更为稳妥一些。
     * @param word 目标词
     * @return IDF
     */
    public double getIDF(String word) {
        return Math.log(documentNumber()*1.0/(wordNumber(word)+1));
    }
}

完整实例

下面给出一个针对单个文本的TF-IDF计算工具类,提供TF-IDF计算和关键词获取两种功能:

import com.google.common.collect.Maps;
import io.github.yizhiru.thulac4j.POSTagger;
import io.github.yizhiru.thulac4j.term.TokenItem;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.stereotype.Service;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
public class TFIDFService {
    private static final Logger LOGGER = LoggerFactory.getLogger(TFIDFService.class);

    @Autowired private IDFCacheService idfCacheService;

    @Autowired private HashOperations hashOperations;

    @Value("{segment.weightPath}")
    private String weightPath;

    @Value("{segment.featurePath}")
    private String featurePath;

    /**
     * 处理分析某个文本,分别计算和存储TF值和IDF值
     * @param is 文本流,需要在外部关闭流
     * @param tId 文本编号
     */
    public void analyse(InputStream is, String tId) {

        try(BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
            // 分词
            POSTagger posTagger = new POSTagger(weightPath, featurePath);
            List tokenItems = reader.lines()
                    .map(posTagger::tokenize)
                    .flatMap(Collection::stream)
                    .filter(tokenItem -> tokenItem.word.length() > 1)
                    .collect(Collectors.toList());

            // 计算词频
            final int totalWord = tokenItems.size();
            Map tfMap = tokenItems.stream()
                    .collect(Collectors.groupingBy(TokenItem::toString, Collectors.counting()))
                    .entrySet().stream()
                    .collect(Collectors.toMap(Map.Entry::getKey, en -> en.getValue() * 1.0 / totalWord));

            // 缓存IDF
            idfCacheService.documentNumberIncrement();
            idfCacheService.wordNumberIncrement(tfMap.keySet());

            // 保存该文本的词频
            tfMap.forEach((k, v) -> {
                hashOperations.put(tId, k, v);
            });
        } catch (Exception e) {
            LOGGER.warn("计算文本词条词频异常:{}", tId, e);
        }
    }

    /**
     * 获取文本所有词条的tf-idf值
     * @param tId 文本编号
     * @return 词条和tf-idf值的映射表
     */
    public Map getTFIDF(String tId) {
        return hashOperations.keys(tId)
                .stream().map(w -> {
                    Double tf = (Double) hashOperations.get(tId, w);
                    if (null == tf) {
                        return Maps.immutableEntry(w, 0.0);
                    }
                    double idf = idfCacheService.getIDF(w);
                    return Maps.immutableEntry(w, tf * idf);
                })
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

    }

    /**
     * 获取文本关键字
     * @param tId 文本编号
     * @param size 关键字个数
     * @return 关键字列表
     */
    public List keyWords(String tId, int size) {
        Map tfidf = getTFIDF(tId);
        return tfidf.entrySet().stream()
                .sorted(Map.Entry.comparingByValue((o1, o2) -> Double.compare(o2, o1)))
                .limit(size).map(Map.Entry::getKey).collect(Collectors.toList());
    }
}

以上代码中analyse方法需要单独线程或进程一直分析语料库,分析的预料越多,关键字提取的越准确。

需要获取关键的时候,直接调用keyWords方法即可,不需要入库,毕竟随着分析的预料越多,这个方法的返回值有可能发生变化。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注