2021SC@SDUSC
核心内容
一、本文分析目标
上一篇博客我们分析了创建ELSKE模型的主要函数调用,分别负责读取文件信息FromFile(),然后提取关键词ExtractPhrases(),我们认识到这两个函数的实现机制是在之前定义的变量的基础上,调用其他类的其他函数实现更复杂的操作。这一篇内容,我们来分析其部分细节。
二、BinaryDocumentIndex的基本实现
1. 基本的声明
我们首先关注BinaryDocumentIndex.cs这个文件的实现情况,其文件主要在ElskeLib的Utils模块下。根据模块的注释,这个类是用于快速得出>= 2个词语的词组的文档频率的。它的是一个基于整数的文档集合,并有着基于bigram的索引。
关注这个类的定义部分,主要有下面变量:
1.一个字典_dictionary,前一项是WordIdxBigram,后一项是 List,用于表示某一个词项出现在文档的具体位置。
2.一个文档列表,它的每一项都是一个int[]数组,代表一个文档,整个列表组成一个文档集合。
3.一个_stopWords停用词集合,其中每一个元素都是一个整数型的索引。这个变量是由HashSet实现的。通过查询资料,我们可以发现其实HashSet是一个容器,它就代表一个集合,并其中不能有重复的元素。其集合内的元素没有顺序,并且有一组高性能的操作。
4.接下来定义一个列表Documents其中的每一个元素是一个整数型的数组,它的类型是IReadOnlyList,通过查资料,我了解到这个类主要功能是使得一个列表是只读的,不可以进行修改。我们考虑到它实现的基础是_documents本身生成Documents,因此这里初始化使用了一个lambda表达式,使用了=>语法。
5.最后将_stopWords封装成一个只读的集合StopWords,具体的方式和上面的Documents类似。
变量名称 | 具体类型 |
---|---|
_dictionary | Dictionary<WordIdxBigram, List> |
_documents | List<int[]> |
_stopWords | HashSet |
Documents | IReadOnlyList<int[]> |
StopWords | IReadOnlyCollection |
// 为了加速提取速度需要使用的结构与数据
private readonly Dictionary<WordIdxBigram, List<SequencePosition>> _dictionary =
new Dictionary<WordIdxBigram, List<SequencePosition>>();
private readonly List<int[]> _documents = new List<int[]>();
private readonly HashSet<int> _stopWords;
public IReadOnlyList<int[]> Documents => _documents;
public IReadOnlyCollection<int> StopWords => _stopWords;
2.初始化
首先是这个类的构造函数,由于这个类完成一个基于整数的文档集合的转化。因此它需要知道哪些词语不可以转化。这样的一些词是停用词。我们将这些词进行作为唯一的参数输入,并将其赋值给_stopWords就完成了初始化工作。
public BigramDocumentIndex(HashSet<int>? stopWords)
{
_stopWords = stopWords;
}
还有另一类初始化函数,它除了输入停用词之外,还会继续输入文档集合以及字典。
private BigramDocumentIndex(HashSet<int> stopWords, List<int[]> documents,
Dictionary<WordIdxBigram, List<SequencePosition>> dict) : this(stopWords)
{
_documents = documents;
_dictionary = dict;
}
3. 添加文档和获得文档频率
由于输入输出文件的函数FromFile和ToFile涉及到的是一系列与IO有关的操作,因此我们不再过多去分析他们,而是转向分析其他的函数。
我们的目标是分析AddDocument和GetDocumentFrequency这两个函数。
-
- 前者AddDocument完成将一个新文档加入到索引集合的操作。
-
- 后者GetDocumentFrequency完成的工作是给定一个词组(根据这个类的具体作用,这个词组至少是Bigram),他会对应一个编号,我们要找出其出现过的文档总数,也就是计算文档频率。下面两种情况会返回0:
- a) 如果给定的词组只包含停用词表StopWords中的条目;
- b) 如果所含的词数小于2。
4. AddDocument函数
我们先来考察如何添加新的文档用以了解文档的结构和组织。函数头如下:
public void AddDocument(int[] doc)
- 首先判断文档是否等于null,若是则抛出异常,否则进行下一步;
- 我们用documentIndex来表示文档的索引号;
- 在_documents集合中添加文档doc;
- 对文档的每一个词语进行遍历,每次遍历都创建一个新的bigram,然后考虑这个bigram是否可以被加入文档集合的这个文档中,作为一个指标。
考虑的方法是:
-
若_stopWords不为null,并且_stopWords中同时存在bigram的两个组成词,就不将它放入文档集合,而是继续判断下一个bigram。
-
否则就在_dictionary中添加这个词项,前一项为(doc[i - 1], doc[i])表示bigram,后一项为SequencePosition,用于表示该项bigram所在的文档位置documentIndex以及文档中出现的位置i-1。
if (doc == null)
throw new ArgumentNullException(nameof(doc));
var documentIndex = _documents.Count;
_documents.Add(doc);
for (int i = 1; i < doc.Length; i++)
{
var k = new WordIdxBigram(doc[i - 1], doc[i]);
if(_stopWords != null && _stopWords.Contains(k.Idx1) && _stopWords.Contains(k.Idx2))
continue;
_dictionary.AddToList(k, new SequencePosition(documentIndex, i-1));
}
通过分析这个类我们了解到,新添加的文档无非就是想要改变_documents和字典_dictionary,为了简化计算,我们把那些不合规定的bigram直接略去了。
5. GetDocumentFrequency函数
我们接下来继续考察如何根据已添加的文档计算给定的长词组(至少大于等于2)文档频率。这一步至关重要,涉及到了模型的核心部分。
函数输入给定的长词组,它是一个只读的span,其名称为tokens,类型为int,可以查询资料了解到span对于处理数组的某一段十分高效。
- 先定义一个bigram的位置对象SequencePosition的列表bestList,将其初始化为null;
- 然后定义tokenOffset初始化为0;
- 对于每一个bigram,每次找出其在字典中出现的位置列表,然后考察列表长度,找出最小列表长度以及其对应的bigram。
- 返回0,代表这个词组中确是存在匹配不上的bigram序列。
- 返回docHashSet.Count,代表通过逐一考察最小词频对应的bigram,考察其位置是否有输入的词组,选出有完整词组的文档序号,计算其数量并成功返回。
public int GetDocumentFrequency(ReadOnlySpan<int> tokens)
{
List<SequencePosition> bestList = null;
var tokenOffset = 0;
for (int i = 1; i < tokens.Length; i++)
{
var k = new WordIdxBigram(tokens[i - 1], tokens[i]);
if (_dictionary.TryGetValue(k, out var list))
{
if (bestList == null || bestList.Count > list.Count)
{
bestList = list;
tokenOffset = i - 1;
}
}
else
{
if (StopWords != null && (!_stopWords.Contains(k.Idx1) || !_stopWords.Contains(k.Idx2)))
return 0; //we know already that there cannot be a match
}
}
if (bestList == null || bestList.Count == 0)
return 0;
var docHashSet = new HashSet<int>();
for (int i = 0; i < bestList.Count; i++)
{
var pos = bestList[i];
if(docHashSet.Contains(pos.DocumentIndex))
continue; //we already know that this document contains the sequence
var doc = _documents[pos.DocumentIndex];
var startPosInDoc = pos.Position - tokenOffset;
var endPosInDoc = startPosInDoc + tokens.Length;
if(startPosInDoc < 0 || endPosInDoc > doc.Length)
continue;
if (tokens.SequenceEqual(new ReadOnlySpan<int>(doc, startPosInDoc, tokens.Length)))
docHashSet.Add(pos.DocumentIndex);
}
return docHashSet.Count;
}
三、SequencePosition结构
本文件中还定义了一个十分有用的结构体SequencePosition,这个结构体用于表示某个Bigram出现的具体位置,包含文档号和位置序号。
public readonly struct SequencePosition
{
public SequencePosition(int documentIndex, int position)
{
DocumentIndex = documentIndex;
Position = position;
}
public int DocumentIndex { get; }
public int Position { get; }
}
四、总结
这个为难主要目的是,将文档组合成一个特殊的结构,其中以bigram为基础,并提前去除了停用词,每当有新的词进入的时候,就可以将其的文档频率输出。阅读这个文件,我发现它实质上就是实现了前文论文分析中的提取3,4,5…元词组一步。通过特殊的结构加速计算结果。
主要在寻找候选词的时候考虑到了如下规律:2元词组的频率 f s ( p j ) f_{s}(p^{j}) fs(pj)是任何包含这个词组的父词组的最大频率。
下文,我们将继续先分析其他的类,并最终进入到提取关键词的整体算法分析。