自然语言处理入门
分词流程及结果分析#
最长匹配#
以某个下标为起点递增查词的过程中,优先输出更长的单词,这种规则称为最长匹配算法。根据扫描顺序可分为正向最长匹配,逆向最长匹配。
流程#
正向最长匹配的中文分词算法:
/**
* 正向最长匹配的中文分词算法
*
* @param text 待分词的文本
* @param dictionary 词典
* @return 单词列表
*/
public static List<String> segmentForwardLongest(String text, Map<String, CoreDictionary.Attribute> dictionary)
{
List<String> wordList = new LinkedList<String>();
for (int i = 0; i < text.length(); )
{
String longestWord = text.substring(i, i + 1);
for (int j = i + 1; j <= text.length(); ++j)
{
String word = text.substring(i, j);
if (dictionary.containsKey(word))
{
if (word.length() > longestWord.length())
{
longestWord = word;
}
}
}
wordList.add(longestWord);
i += longestWord.length();
}
return wordList;
}
我们可以看到i从0开始不断加,是正向。if判断word长度更新最长长度,是最长匹配。
逆向最长匹配的中文分词算法:
/**
* 逆向最长匹配的中文分词算法
*
* @param text 待分词的文本
* @param dictionary 词典
* @return 单词列表
*/
public static List<String> segmentBackwardLongest(String text, Map<String, CoreDictionary.Attribute> dictionary)
{
List<String> wordList = new LinkedList<String>();
for (int i = text.length() - 1; i >= 0; )
{
String longestWord = text.substring(i, i + 1);
for (int j = 0; j <= i; ++j)
{
String word = text.substring(j, i + 1);
if (dictionary.containsKey(word))
{
if (word.length() > longestWord.length())
{
longestWord = word;
break;
}
}
}
wordList.add(0, longestWord);
i -= longestWord.length();
}
return wordList;
}
我们可以看到i从0开始不断减,是逆向。if判断word长度更新最长长度,是最长匹配。
双向最长匹配的中文分词算法:
/**
* 双向最长匹配的中文分词算法
*
* @param text 待分词的文本
* @param dictionary 词典
* @return 单词列表
*/
public static List<String> segmentBidirectional(String text, Map<String, CoreDictionary.Attribute> dictionary)
{
List<String> forwardLongest = segmentForwardLongest(text, dictionary);
List<String> backwardLongest = segmentBackwardLongest(text, dictionary);
if (forwardLongest.size() < backwardLongest.size())
return forwardLongest;
else if (forwardLongest.size() > backwardLongest.size())
return backwardLongest;
else
{
if (countSingleChar(forwardLongest) < countSingleChar(backwardLongest))
return forwardLongest;
else
return backwardLongest;
}
}
/**
* 统计分词结果中的单字数量
*
* @param wordList 分词结果
* @return 单字数量
*/
public static int countSingleChar(List<String> wordList)
{
int size = 0;
for (String word : wordList)
{
if (word.length() == 1)
++size;
}
return size;
}
主函数:
/**
* 最长匹配分词,字典使用的为HanLP的字典
* @param testPath 测试集地址
* @param resultPath 生成结果地址
*/
public static void segmentLongest(String testPath, String resultPath) throws IOException {
FileReader fileReader = new FileReader(testPath);
List<String> testCollections = fileReader.readLines();
// 加载词典
TreeMap<String, CoreDictionary.Attribute> dictionary =
IOUtil.loadDictionary("data/dictionary/CoreNatureDictionary.mini.txt");
System.out.printf("词典大小:%d个词条\n", dictionary.size());
System.out.println(dictionary.keySet().iterator().next());
// 正向最长匹配,逆向最长匹配,双向最长匹配
for (int i = 0; i < testCollections.size(); i++) {
String result = StrFormatter.format("| {} | {} | {} | {} | {} |\n",
i + 1,
testCollections.get(i),
segmentForwardLongest(testCollections.get(i), dictionary),
segmentBackwardLongest(testCollections.get(i), dictionary),
segmentBidirectional(testCollections.get(i), dictionary));
FileWriter fileWriter = new FileWriter(resultPath);
fileWriter.append(result);
}
}
首先读取找的1200个句子:
按行读取,放入list,加载hanLP的字典,分别对每行句子进行正向最长匹配,逆向最长匹配,双向最长匹配。将得到的结果存到另一个txt文件中。
结果分析#
由于当初每个句子选取较长,导致看起来比较混乱。每句前面有序号,每种最长匹配用 | 分割。
我们看第三句,所有的对于正向最长匹配,结果为:所有,的
对于逆向最长匹配,结果为:所,有的
从正向来说,自然是希望左边的词长度更长,对于逆向来说,自然是希望右边的词长度更长。所以才有了以上结果。
我们看第5句,大人们对于正向和逆向的结果分别为:大人,们------大,人们
由此人们提出了双向最长匹配,同时执行正向和逆向最长匹配,若两者的词数不同,优先返回词数更少的哪一个。否则,返回两者中单字更少的那一个。当单字也相同,优先返回逆向最长匹配的结果。但是我们从第三和第五局中的双向匹配结果来看,其正确率并不高,所以规则集的维护有时是太东墙补西墙,帮倒忙。
二元语法#
流程#
public static void main(String[] args) throws IOException
{
trainBigram(MSR.TRAIN_PATH, MSR_MODEL_PATH);
Segment segment = loadBigram(MSR_MODEL_PATH);
evaluate(segment, MSR.OUTPUT_PATH, MSR.TEST_PATH, MSR.TRAIN_WORDS);
}
首先根据训练集训练模型,然后根据模型得到segment,通过测试集得到预测集。
首先我们需要训练集,我是直接使用hanLP的训练集msr_training.utf8
:
我们使用训练集训练出模型:
/**
* 训练bigram模型
*
* @param corpusPath 语料库路径
* @param modelPath 模型保存路径
*/
public static void trainBigram(String corpusPath, String modelPath)
{
List<List<IWord>> sentenceList = CorpusLoader.convert2SentenceList(corpusPath);
for (List<IWord> sentence : sentenceList)
for (IWord word : sentence)
if (word.getLabel() == null) word.setLabel("n"); // 赋予每个单词一个虚拟的名词词性
final NatureDictionaryMaker dictionaryMaker = new NatureDictionaryMaker();
dictionaryMaker.compute(sentenceList);
dictionaryMaker.saveTxtTo(modelPath);
}
通过模型得到segment:
/**
* 加载bigram模型
*
* @param modelPath 模型路径
* @param verbose 输出调试信息
* @param viterbi 是否创建viterbi分词器
* @return 分词器
*/
public static Segment loadBigram(String modelPath, boolean verbose, boolean viterbi)
{
// HanLP.Config.enableDebug();
HanLP.Config.CoreDictionaryPath = modelPath + ".txt";
HanLP.Config.BiGramDictionaryPath = modelPath + ".ngram.txt";
CoreDictionary.reload();
CoreBiGramTableDictionary.reload();
// 以下部分为兼容新标注集,不感兴趣可以跳过
HanLP.Config.CoreDictionaryTransformMatrixDictionaryPath = modelPath + ".tr.txt";
if (!modelPath.equals(MSR_MODEL_PATH))
{
IOUtil.LineIterator lineIterator = new IOUtil.LineIterator(HanLP.Config.CoreDictionaryTransformMatrixDictionaryPath);
if (lineIterator.hasNext())
{
for (String tag : lineIterator.next().split(","))
{
if (!tag.trim().isEmpty())
{
Nature.create(tag);
}
}
}
}
}
segment为分词器,得到分词器就可以把测试集分词了,改写evaluate:
public static CWSEvaluator.Result evaluate(Segment segment, String outputPath, String testFile, String dictPath) throws IOException {
IOUtil.LineIterator lineIterator = new IOUtil.LineIterator(testFile);
BufferedWriter bw = IOUtil.newBufferedWriter(outputPath);
for (String line : lineIterator) {
List<Term> termList = segment.seg(line.replaceAll("\\s+", "")); // 一些testFile与goldFile根本不匹配,比如MSR的testFile有些行缺少单词,所以用goldFile去掉空格代替
int i = 0;
for (Term term : termList) {
bw.write(term.word);
if (++i != termList.size())
bw.write(" ");
}
bw.newLine();
}
bw.close();
CWSEvaluator.Result result = CWSEvaluator.evaluate(testFile, outputPath, dictPath);
return result;
}
我们可以得到outputPath,我们能得到预测出的文件就行了。
得到的结果为:
结果分析#
由于人名,地名和机构名等OOV无法识别造成分词过细,结论:OVV召回能力太差。
比如北凉王府,异姓王,徐蛮子,王府,徐骁,小王爷,六国,大柱国等都被分割,那么解决办法就是调整模型,向模型中添加:
北凉王府 ns 1
异姓王 nr 1
徐蛮子 nr 1
王府 ns 1
徐骁 nr 1
条件随机场#
流程#
Java训练模型太慢了,我训练了半小时,然后一看堆堆内存溢出了!!!!!!!
我们下载c++的CRF++来训练模型:
crf_learn.exe用来训练模型,最终得到模型crfpp-model.txt:
根据我们找的1200句子训练模型:
得到模型为:
得到模型后,执行crf_test.exe进行预测:
可以看到最后一列的预测结果与标准答案完全一致!!
结果分析:
条件随机场的各项指标全面胜过了结构化感知机,综合F1更高,是传统方法总最准确的分词模型!!!
总结#
我们从最基础的速度最快的字典分词,到准确率最高的条件随机场。一路走来,一步步对预测进行优化。
字典分词能达到每秒千万字的速度,但是准确率并不高,无法区分歧义,也无法召回新词。
从二元语法分词,我们掌握了NLP中最重要的概念之一:语言模型。相较于词典分词,二元语法分词器在MSR语料库上的F1值提高了0.025.二元语法分词有些问题,为了解决问题,我们需要调整模型,但是OOV召回仍然不足。
隐马尔可夫模型是最简单的序列标注模型,基本问题有三个:样本生成,参数估计,序列预测。中文分词效果并不理想,虽然找回了一半的OOV,但是综合F1甚至低于词典分词。我们要更高级的模型。
结构化感知机是更高级的模型,他的召回了百分之七十的为登录词,那么还有更高的准确率么?
条件随机场的各项指标都胜过结构化感知机,是传统方法中最准确的分词模型。至此,我们中文分词就基本完成了。
但这还不够,我们仍然通过机器学习结合语料库,成长成为自然语言处理工程师!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库