DynamicArray与NShortPath是ICTCLAS中的基础类,本人在完成了基础改造工作后,就着手开始对Segment分词进行移植与改造。SharpICTCLAS中的改造主要体现在以下几方面:
1)合并不同类中的部分代码
原有ICTCLAS中使用了SegGraph与Segment两个类完成分词过程,SegGraph类负责完成原子分词与segGraph的生成,而Segment类负责BiSegGraph的生成和NShortPath优化,而最终的人名、地名识别以及Optimum优化又分散在了Segment类与WordSegment类中。
SharpICTCLAS将原有SegGraph类与Segment合二为一,因为它们所作的工作仅仅是分词中的几个步骤而已。而WordSegment类中基本保留了原有内容,因为这个类更多的做一些外围工作。
2)改造了程序中用到的部分数据结构
原有ICTCLAS大量使用了数组与二维数组,由于数组的固有缺陷使得我们随处可以看到如此这般的数组定义:
m_pWordSeg = new PWORD_RESULT[MAX_SEGMENT_NUM];
由于不知道最终会分成几个词,所以定义数组时只能用最大的容量 MAX_SEGMENT_NUM
进行预设,所以一旦存在某些异常数据就会造成“溢出”错误。
而SharpICTCLAS中大量使用了 List<int[]>
的方式记录结果 ,范型的List首先可以确保结果集的数量可以动态调整而不用事先定义,另外每个结果的数组长度也可各不相同。
再有的改造就是在Segment类中使用了链表结构处理结果,这大大简化了原有ICTCLAS中的数组结构带来的种种问题。
3)大量使用了静态方法
由于某些过程的调用根本不需要建立对象,这些过程仅仅完成例行计算而已,因此将这些方法声明为静态方法更合适,何况静态方法的调用效率比实例方法高。因此本人在将ICTCLAS移植到C#平台上时,将尽可能的方法定义成静态方法。
下面我就说说SharpICTCLAS中Segment类的一些主要内容:
1、主体部分
比较典型的一个运算过程可以参考BiSegment方法,代码(经过简化)如下:
{
WordResult[] tmpResult;
WordLinkedArray linkedArray;
m_pWordSeg = new List<WordResult[]>();
m_graphOptimum = new RowFirstDynamicArray<ChainContent>();
//---原子分词
atomSegment = AtomSegment(sSentence);
//---检索词库,加入所有可能分词方案并存入链表结构
segGraph = GenerateWordNet(atomSegment, coreDict);
//---检索所有可能的两两组合
biGraphResult = BiGraphGenerate(segGraph, smoothPara, biDict, coreDict);
//---N 最短路径计算出多个分词方案
NShortPath.Calculate(biGraphResult, nKind);
List<int[]> spResult = NShortPath.GetNPaths(Predefine.MAX_SEGMENT_NUM);
//---对结果进行优化,例如合并日期等工作
for (int i = 0; i < spResult.Count; i++)
{
linkedArray = BiPath2LinkedArray(spResult[i], segGraph, atomSegment);
tmpResult = GenerateWord(spResult[i], linkedArray, m_graphOptimum);
if (tmpResult != null)
m_pWordSeg.Add(tmpResult);
}
return m_pWordSeg.Count;
}
从上面代码可以看出,已经将原有ICTCLAS的原子分词功能合并入Segment类了。
就拿“他在1月份大会上说的确实在理”这句话来说,上面几个步骤得到的中间结果如下:
他在1月份大会上说的确实在理
//==== 原子切分:
始##始, 他, 在, 1, 月, 份, 大, 会, 上, 说, 的, 确, 实, 在, 理, 末##末,
//==== 生成 segGraph:
row: 0, col: 1, eWeight: 329805.00, nPOS: 1, sWord:始##始
row: 1, col: 2, eWeight: 19823.00, nPOS: 0, sWord:他
row: 2, col: 3, eWeight: 78484.00, nPOS: 0, sWord:在
row: 3, col: 4, eWeight: 0.00, nPOS: -27904, sWord:未##数
row: 4, col: 5, eWeight: 1900.00, nPOS: 0, sWord:月
row: 4, col: 6, eWeight: 11.00, nPOS: 28160, sWord:月份
row: 5, col: 6, eWeight: 1234.00, nPOS: 0, sWord:份
row: 6, col: 7, eWeight: 14536.00, nPOS: 0, sWord:大
row: 6, col: 8, eWeight: 1333.00, nPOS: 28160, sWord:大会
row: 7, col: 8, eWeight: 6136.00, nPOS: 0, sWord:会
row: 7, col: 9, eWeight: 469.00, nPOS: 0, sWord:会上
row: 8, col: 9, eWeight: 23706.00, nPOS: 0, sWord:上
row: 9, col: 10, eWeight: 17649.00, nPOS: 0, sWord:说
row: 10, col: 11, eWeight: 358156.00, nPOS: 0, sWord:的
row: 10, col: 12, eWeight: 210.00, nPOS: 25600, sWord:的确
row: 11, col: 12, eWeight: 181.00, nPOS: 0, sWord:确
row: 11, col: 13, eWeight: 361.00, nPOS: 0, sWord:确实
row: 12, col: 13, eWeight: 357.00, nPOS: 0, sWord:实
row: 12, col: 14, eWeight: 295.00, nPOS: 0, sWord:实在
row: 13, col: 14, eWeight: 78484.00, nPOS: 0, sWord:在
row: 13, col: 15, eWeight: 3.00, nPOS: 24832, sWord:在理
row: 14, col: 15, eWeight: 129.00, nPOS: 0, sWord:理
row: 15, col: 16, eWeight:2079997.00, nPOS: 4, sWord:末##末
//==== 生成 biSegGraph:
row: 0, col: 1, eWeight: 3.37, nPOS: 1, sWord:始##始@他
row: 1, col: 2, eWeight: 3.37, nPOS: 0, sWord:他@在
row: 2, col: 3, eWeight: 3.74, nPOS: 0, sWord:在@未##数
row: 3, col: 4, eWeight: -27898.79, nPOS: -27904, sWord:未##数@月
row: 3, col: 5, eWeight: -27898.75, nPOS: -27904, sWord:未##数@月份
row: 4, col: 6, eWeight: 9.33, nPOS: 0, sWord:月@份
row: 5, col: 7, eWeight: 13.83, nPOS: 28160, sWord:月份@大
row: 6, col: 7, eWeight: 9.76, nPOS: 0, sWord:份@大
row: 5, col: 8, eWeight: 13.83, nPOS: 28160, sWord:月份@大会
row: 6, col: 8, eWeight: 9.76, nPOS: 0, sWord:份@大会
row: 7, col: 9, eWeight: 7.30, nPOS: 0, sWord:大@会
row: 7, col: 10, eWeight: 7.30, nPOS: 0, sWord:大@会上
row: 8, col: 11, eWeight: 2.11, nPOS: 28160, sWord:大会@上
row: 9, col: 11, eWeight: 8.16, nPOS: 0, sWord:会@上
row: 10, col: 12, eWeight: 3.42, nPOS: 0, sWord:会上@说
row: 11, col: 12, eWeight: 4.07, nPOS: 0, sWord:上@说
row: 12, col: 13, eWeight: 4.05, nPOS: 0, sWord:说@的
row: 12, col: 14, eWeight: 7.11, nPOS: 0, sWord:说@的确
row: 13, col: 15, eWeight: 4.10, nPOS: 0, sWord:的@确
row: 13, col: 16, eWeight: 4.10, nPOS: 0, sWord:的@确实
row: 14, col: 17, eWeight: 11.49, nPOS: 25600, sWord:的确@实
row: 15, col: 17, eWeight: 11.63, nPOS: 0, sWord:确@实
row: 14, col: 18, eWeight: 11.49, nPOS: 25600, sWord:的确@实在
row: 15, col: 18, eWeight: 11.63, nPOS: 0, sWord:确@实在
row: 16, col: 19, eWeight: 3.92, nPOS: 0, sWord:确实@在
row: 17, col: 19, eWeight: 10.98, nPOS: 0, sWord:实@在
row: 16, col: 20, eWeight: 10.97, nPOS: 0, sWord:确实@在理
row: 17, col: 20, eWeight: 10.98, nPOS: 0, sWord:实@在理
row: 18, col: 21, eWeight: 11.17, nPOS: 0, sWord:实在@理
row: 19, col: 21, eWeight: 5.62, nPOS: 0, sWord:在@理
row: 20, col: 22, eWeight: 14.30, nPOS: 24832, sWord:在理@末##末
row: 21, col: 22, eWeight: 11.95, nPOS: 0, sWord:理@末##末
//==== NShortPath 初步切分的到的 N 个结果:
始##始, 他, 在, 1, 月份, 大会, 上, 说, 的, 确实, 在, 理, 末##末,
始##始, 他, 在, 1, 月份, 大会, 上, 说, 的, 确实, 在理, 末##末,
始##始, 他, 在, 1, 月份, 大, 会上, 说, 的, 确实, 在, 理, 末##末,
始##始, 他, 在, 1, 月, 份, 大会, 上, 说, 的, 确实, 在, 理, 末##末,
始##始, 他, 在, 1, 月份, 大, 会上, 说, 的, 确实, 在理, 末##末,
//==== 经过数字、日期合并等策略处理后的 N 个结果:
始##始, 他, 在, 1月份, 大会, 上, 说, 的, 确实, 在, 理, 末##末,
始##始, 他, 在, 1月份, 大会, 上, 说, 的, 确实, 在理, 末##末,
始##始, 他, 在, 1月份, 大, 会上, 说, 的, 确实, 在, 理, 末##末,
始##始, 他, 在, 1月, 份, 大会, 上, 说, 的, 确实, 在, 理, 末##末,
始##始, 他, 在, 1月份, 大, 会上, 说, 的, 确实, 在理, 末##末,
这些内容在前面的文章中已经涉及过,我这里主要说说SharpICTCLAS中两处地方的内容,分别是原子分词以及数字日期合并策略。
2、原子分词
原子分词看起来应当是程序中最简单的部分,无非是将汉字逐一分开。但是也是最值得改进的地方。SharpICTCLAS目前仍然沿用了原有ICTCLAS的算法并做了微小调整。但我对于 这种原子分词方法不太满意,如果有机会,可以考虑使用一系列正则表达式将某些“原子”词单独摘出来。比如“甲子”、“乙亥”等年份信息属于原子信息,还有URL、Email等都可以预先进行原子识别,这可以大大简化后续工作。因此日后可以考虑这方面的处理。
3、对结果的处理
ICTCLAS与SharpICTCLAS都通过NShortPath计算最短路径并将结果以数组的方式进行输出,数组仅仅记录了分词的位置,我们还需要通过一些后续处理手段将这些数组转换成“分词”结果。
原有ICTCLAS的实现如下:
{
BiPath2UniPath(nSegRoute[i]); //Path convert to unipath
GenerateWord(nSegRoute, i); //Gernerate word according the Segmentation route
i++;
}
其中这个BiPath2UniPath方法做的工作可以用如下案例说明:
例如“他说的确实在理”
BiPath:(0, 1, 2, 3, 6, 9, 11, 12)
0 1 2 3 4 5 6 7 8 9 10 11 12
始##始 他 说 的 的确 确 确实 实 实在 在 在理 理 末##末
经过转换后
UniPath:(0, 1, 2, 3, 4, 6, 7, 8)
0 1 2 3 4 5 6 7 8
始##始 他 说 的 确 实 在 理 末##末
由此可见UniPath记录了针对原子分词的分割位置。而后面的GenerateWord方法又针对这个数组去做合并及优化工作。
本人在SharpICTCLAS的改造过程中发现在这里数组的表述方式给后续工作带来了很大的困难(可以考虑一下,让你合并链表中两个相邻结点简单呢还是数组中两个相邻结点简单?),所以我决定在SharpICTCLAS中将BiPath转换为链表结构供后续使用,实践证明简化了不少工作。
这点在BiSegment方法中有所体现,如下:
linkedArray = BiPath2LinkedArray(spResult[i], segGraph, atomSegment);
这样改造后,还使得原有ICTCLAS中 int *m_npWordPosMapTable;
不再需要,与其相关的代码也可以一并删除了。
4、日期、数字合并策略
数字、日期等合并以及拆分策略的实施是在GenerateWord方法中实现的,原有ICTCLAS中,该方法是一个超级庞大的方法,里面有不下6、7层的if嵌套、while嵌套等,分析其内部功能的工作异常复杂。经过一番研究后,我将其中的主要功能部分提取出来,改用了“管道”方式进行处理,简化了代码复杂度。但对于部分逻辑结构异常复杂的日期时间识别功能,SharpICTCLAS中仍然保留了绝大多数原始内容。
让我们先来看看原始ICTCLAS的GenerateWord方法(超级长的一个方法):
bool CSegment::GenerateWord(int **nSegRoute, int nIndex)
{
unsigned int i = 0, k = 0;
int j, nStartVertex, nEndVertex, nPOS;
char sAtom[WORD_MAXLENGTH], sNumCandidate[100], sCurWord[100];
ELEMENT_TYPE fValue;
while (nSegRoute[nIndex][i] != - 1 && nSegRoute[nIndex][i + 1] != - 1 &&
nSegRoute[nIndex][i] < nSegRoute[nIndex][i + 1])
{
nStartVertex = nSegRoute[nIndex][i];
j = nStartVertex; //Set the start vertex
nEndVertex = nSegRoute[nIndex][i + 1]; //Set the end vertex
nPOS = 0;
m_graphSeg.m_segGraph.GetElement(nStartVertex, nEndVertex, &fValue, &nPOS);
sAtom[0] = 0;
while (j < nEndVertex)
{
//Generate the word according the segmentation route
strcat(sAtom, m_graphSeg.m_sAtom[j]);
j++;
}
m_pWordSeg[nIndex][k].sWord[0] = 0; //Init the result ending
strcpy(sNumCandidate, sAtom);
while (sAtom[0] != 0 && (IsAllNum((unsigned char*)sNumCandidate) ||
IsAllChineseNum(sNumCandidate)))
{
//Merge all seperate continue num into one number
//sAtom[0]!=0: add in 2002-5-9
strcpy(m_pWordSeg[nIndex][k].sWord, sNumCandidate);
//Save them in the result segmentation
i++; //Skip to next atom now
sAtom[0] = 0;
while (j < nSegRoute[nIndex][i + 1])
{
//Generate the word according the segmentation route
strcat(sAtom, m_graphSeg.m_sAtom[j]);
j++;
}
strcat(sNumCandidate, sAtom);
}
unsigned int nLen = strlen(m_pWordSeg[nIndex][k].sWord);
if (nLen == 4 && CC_Find("第上成±—+∶·./",
m_pWordSeg[nIndex][k].sWord) || nLen == 1 && strchr("+-./",
m_pWordSeg[nIndex][k].sWord[0]))
{
//Only one word
strcpy(sCurWord, m_pWordSeg[nIndex][k].sWord); //Record current word
i--;
}
else if (m_pWordSeg[nIndex][k].sWord[0] == 0)
//Have never entering the while loop
{
strcpy(m_pWordSeg[nIndex][k].sWord, sAtom);
//Save them in the result segmentation
strcpy(sCurWord, sAtom); //Record current word
}
else
{
//It is a num
if (strcmp("--", m_pWordSeg[nIndex][k].sWord) == 0 || strcmp("—",
m_pWordSeg[nIndex][k].sWord) == 0 || m_pWordSeg[nIndex][k].sWord[0] ==
'-' && m_pWordSeg[nIndex][k].sWord[1] == 0)
//The delimiter "--"
{
nPOS = 30464; //'w'*256;Set the POS with 'w'
i--; //Not num, back to previous word
}
else
{
//Adding time suffix
char sInitChar[3];
unsigned int nCharIndex = 0; //Get first char
sInitChar[nCharIndex] = m_pWordSeg[nIndex][k].sWord[nCharIndex];
if (sInitChar[nCharIndex] < 0)
{
nCharIndex += 1;
sInitChar[nCharIndex] = m_pWordSeg[nIndex][k].sWord[nCharIndex];
}
nCharIndex += 1;
sInitChar[nCharIndex] = '\0';
if (k > 0 && (abs(m_pWordSeg[nIndex][k - 1].nHandle) == 27904 || abs
(m_pWordSeg[nIndex][k - 1].nHandle) == 29696) && (strcmp(sInitChar,
"—") == 0 || sInitChar[0] == '-') && (strlen
(m_pWordSeg[nIndex][k].sWord) > nCharIndex))
{
//3-4月 //27904='m'*256
//Split the sInitChar from the original word
strcpy(m_pWordSeg[nIndex][k + 1].sWord, m_pWordSeg[nIndex][k].sWord +
nCharIndex);
m_pWordSeg[nIndex][k + 1].dValue = m_pWordSeg[nIndex][k].dValue;
m_pWordSeg[nIndex][k + 1].nHandle = 27904;
m_pWordSeg[nIndex][k].sWord[nCharIndex] = 0;
m_pWordSeg[nIndex][k].dValue = 0;
m_pWordSeg[nIndex][k].nHandle = 30464; //'w'*256;
m_graphOptimum.SetElement(nStartVertex, nStartVertex + 1,
m_pWordSeg[nIndex][k].dValue, m_pWordSeg[nIndex][k].nHandle,
m_pWordSeg[nIndex][k].sWord);
nStartVertex += 1;
k += 1;
}
nLen = strlen(m_pWordSeg[nIndex][k].sWord);
if ((strlen(sAtom) == 2 && CC_Find("月日时分秒", sAtom)) || strcmp
(sAtom, "月份") == 0)
{
//2001年
strcat(m_pWordSeg[nIndex][k].sWord, sAtom);
strcpy(sCurWord, "未##时");
nPOS = - 29696; //'t'*256;//Set the POS with 'm'
}
else if (strcmp(sAtom, "年") == 0)
{
if (IsYearTime(m_pWordSeg[nIndex][k].sWord))
//strncmp(sAtom,"年",2)==0&&
{
//1998年,
strcat(m_pWordSeg[nIndex][k].sWord, sAtom);
strcpy(sCurWord, "未##时");
nPOS = - 29696; //Set the POS with 't'
}
else
{
strcpy(sCurWord, "未##数");
nPOS = - 27904; //Set the POS with 'm'
i--; //Can not be a time word
}
}
else
{
//早晨/t 五点/t
if (strcmp(m_pWordSeg[nIndex][k].sWord + strlen
(m_pWordSeg[nIndex][k].sWord) - 2, "点") == 0)
{
strcpy(sCurWord, "未##时");
nPOS = - 29696; //Set the POS with 't'
}
else
{
if (!CC_Find("∶·./", m_pWordSeg[nIndex][k].sWord + nLen - 2) &&
m_pWordSeg[nIndex][k].sWord[nLen - 1] != '.' &&
m_pWordSeg[nIndex][k].sWord[nLen - 1] != '/')
{
strcpy(sCurWord, "未##数");
nPOS = - 27904; //'m'*256;Set the POS with 'm'
}
else if (nLen > strlen(sInitChar))
{
//Get rid of . example 1.
if (m_pWordSeg[nIndex][k].sWord[nLen - 1] == '.' ||
m_pWordSeg[nIndex][k].sWord[nLen - 1] == '/')
m_pWordSeg[nIndex][k].sWord[nLen - 1] = 0;
else
m_pWordSeg[nIndex][k].sWord[nLen - 2] = 0;
strcpy(sCurWord, "未##数");
nPOS = - 27904; //'m'*256;Set the POS with 'm'
i--;
}
}
i--; //Not num, back to previous word
}
}
fValue = 0;
nEndVertex = nSegRoute[nIndex][i + 1]; //Ending POS changed to latter
}
m_pWordSeg[nIndex][k].nHandle = nPOS; //Get the POS of current word
m_pWordSeg[nIndex][k].dValue = fValue;
//(int)(MAX_FREQUENCE*exp(-fValue));//Return the frequency of current word
m_graphOptimum.SetElement(nStartVertex, nEndVertex, fValue, nPOS, sCurWord);
//Generate optimum segmentation graph according the segmentation result
i++; //Skip to next atom
k++; //Accept next word
}
m_pWordSeg[nIndex][k].sWord[0] = 0;
m_pWordSeg[nIndex][k].nHandle = - 1; //Set ending
return true;
}
SharpICTCLAS中,对这段超长代码进行了功能剥离,采用一种“流水线”式的处理流程,不同工作部分负责处理不同功能,而将处理结果节节传递(很象设计模式中的职责链模式),这样使得整体结构变的清晰起来。SharpICTCLAS中GenerateWord方法定义如下:
RowFirstDynamicArray<ChainContent> m_graphOptimum)
{
if (linkedArray.Count == 0)
return null;
//--------------------------------------------------------------------
//Merge all seperate continue num into one number
MergeContinueNumIntoOne(ref linkedArray);
//--------------------------------------------------------------------
//The delimiter "--"
ChangeDelimiterPOS(ref linkedArray);
//--------------------------------------------------------------------
//如果前一个词是数字,当前词以“-”或“-”开始,并且不止这一个字符,
//那么将此“-”符号从当前词中分离出来。
//例如 “3 / -4 / 月”需要拆分成“3 / - / 4 / 月”
SplitMiddleSlashFromDigitalWords(ref linkedArray);
//--------------------------------------------------------------------
//1、如果当前词是数字,下一个词是“月、日、时、分、秒、月份”中的一个,则合并,且当前词词性是时间
//2、如果当前词是可以作为年份的数字,下一个词是“年”,则合并,词性为时间,否则为数字。
//3、如果最后一个汉字是"点" ,则认为当前数字是时间
//4、如果当前串最后一个汉字不是"∶·./"和半角的'.''/',那么是数
//5、当前串最后一个汉字是"∶·./"和半角的'.''/',且长度大于1,那么去掉最后一个字符。例如"1."
CheckDateElements(ref linkedArray);
//--------------------------------------------------------------------
//遍历链表输出结果
WordResult[] result = new WordResult[linkedArray.Count];
WordNode pCur = linkedArray.first;
int i = 0;
while (pCur != null)
{
WordResult item = new WordResult();
item.sWord = pCur.theWord.sWord;
item.nPOS = pCur.theWord.nPOS;
item.dValue = pCur.theWord.dValue;
result[i] = item;
m_graphOptimum.SetElement(pCur.row, pCur.col, new ChainContent(item.dValue, item.nPOS, pCur.sWordInSegGraph));
pCur = pCur.next;
i++;
}
return result;
}
从中可以看到linkedArray作为“绣球”在多个处理流程中被传递和加工,最终输出相应的结果。只是CheckDateElement方法内容涉及到的东西太多,因此目前看来其实现仍有些臃肿,日后可以进一步进行功能的剥离。
- 小结
1)Segment类是SharpICTCLAS中最大的一个类,实现了分词过程中一些关键的步骤。
2)Segment类对原有ICTCLAS中的代码做了大量修改,力争通过新的数据结构简化原有操作。
3)Segment中定义了部分静态方法以提高调用效率。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步