中文文本智能纠错项目

任务简介

​ 给定一段文本,找出其中可能存在错误的地方。

​ 这是我近期所参与的实验室里的项目,在这个项目里,个人主要负责算法方面的工作。最终目标是实现一个给后台调用的接口。

思路

​ 首先查找相关资料,看看有木有现成的方案。

​ 在文本纠错这一方面,github上有一个比较出名的开源项目——pycorrector。pycorrector里面集成了多个文本纠错模型,而且可以调用自带的模型文件,上手十分方便。这也是前期本人研究了挺久的一个项目。不得不说,这里面资料十分全面,特别适合我这种小白学习。

​ 在一段时间测试后,发现pycorrector里面的模型在文本拼写纠错方面有着不错的能力,但是缺乏语法纠错方面的能力。对于一段文本,如果其中存在一些错别字,pycorrector里面的模型可以比较好地进行识别以及纠正,但如果出现词语残缺,冗余或者乱序等问题,pycreector里面的模型并不能识别。

​ 为了完善pycorrector语法纠错方面的不足,还需要再找一个具有语法纠错能力的模型。于是,后续计划采用双模型结合,实现文本纠错。

准备工作

​ 考虑到双模型在不同环境中可能会产生冲突,所以应当先确定一个环境再配置双模型。

​ 经过各种卸载重装,最终版本采用的环境为 Ubuntu22.04 LTS系统 + python3.7解释器,其他依赖的配置按官方网站的介绍来就好。实测在这个环境下配置下面两个模型一路绿灯。因为个人电脑为Windows系统,所以通过WSL2运行Ubuntu子系统。这里吹一下WSL,配合本地Windows系统的VS Code的远程连接,在不使用桌面服务的情况下也能拥有跟本地一样的开发效率。另外多提一嘴,如果想要配置Ubuntu桌面的话,建议在安装完桌面服务后,下个xrdp,然后利用微软自带的rdp进行远程连接,画质清晰得跟本地一样而且很流畅。

拼写纠错

​ 在pycorrector里面的多个文本纠错模型中,最后选择了采用MacBert模型。该模型具体使用可参考这里

评估结果

​ 因训练集中包含SIGHAN2015,故在SIGHAN2015上达到了SOTA效果

模型 Backbone 评估数据集 Precision Recall F1
*MacBert* *macbert-base-chinese* *SIGHAN2015* *0.8489* *0.7035* *0.7694*
*MacBert* *macbert-base-chinese* *COUR500* *0.9301* *0.5786* *0.7134*

语法纠错

​ 网络上可以简单上手的语法纠错模型比较少,最后采用modelscope里的BART文本纠错模型。具体使用可参考原网址。

评估结果

​ 将双模型结合后,在CGED 2021 数据集上使用官方脚本评测结果如下:

Correct Units: 711 Units With Errors: 1583
Error Type Counts in All Units: 2360 Error Count: 3205 Errors Need Correction: 4165

=====================

System Correct Units: 1002 System Units With Errors: 1293
System Error Type Counts in All Units: 1566 System Error Count: 1900 System Corrected Erros:1525

=====================

False Positive Rate = 0.157524613220816 ( 112 / 711)

=====================

Detction Level
Pre = 0.912606341840681 (1180 / 1293)
Rec = 0.745420088439672 (1180 / 1583)
F1 = 0.820584144645341 (2* 0.912606341840681 * 0.745420088439672 /( 0.912606341840681 + 0.745420088439672 ))

=====================

Identification Level
Pre = 0.741379310344828 (1161 / 1566)
Rec = 0.491949152542373 (1161 / 2360)
F1 = 0.59144167091187 (2* 0.741379310344828 * 0.491949152542373 / ( 0.741379310344828+0.491949152542373 ))

=====================

Postion Level
Pre = 0.306842105263158 ( 583 / 1900 )
Rec = 0.181903276131045 ( 583 / 3205 )
F1 = 0.228403525954946 ( 2 * 0.306842105263158 * 0.181903276131045 /( 0.306842105263158+0.181903276131045 ) )

=====================

Correction Level
Pre = 0.237377049180328 ( 362 / 1525 )
Rec = 0.0869147659063625 ( 362 / 4165 )
F1 = 0.127240773286467 ( 2 * 0.237377049180328 * 0.0869147659063625 /( 0.237377049180328 + 0.0869147659063625 ) )

Comprehensive score = 0.402542528699656

END=

功能函数设计

​ 为了更好实现纠错功能接口以及与前端交互,需要添加一些功能函数。

句子切分

​ 在文本纠错模型中,纠错的单位一般是句子。故在实际使用中需要先完成句子切分。

'''
	src_line:原文本,字符串类型
	end_chars: 切分句子的标志
	result_list: 切分后的句子,列表类型
'''
def get_textlist(src_line):
    end_chars = ['。', '!', '!', '?', '?', '……', ';', ';','\n',' ']
    result_list = []
    str_list = []
    for c in src_line:
        if c in end_chars:
            str_list.append(c)
            result_list.append(''.join(str_list))
            str_list.clear()
        else:
            str_list.append(c)
    if str_list:
        result_list.append(''.join(str_list))
        str_list.clear()
    return result_list

文本差异比对

​ 在bart模型中,纠错返回的结果是句子,为方便前端进行标注,需要实现一个比对功能,根据两个句子的不同返回标注所需要的信息。

'''
	import Levenshtein
	src: 原文本
	tgt: 修改后的文本
	result: 形式如(原文本中需要修改的起始位置,终止位置,错误类型,应当替换成的词)的列表
'''
def get_corrlist(src,tgt):
    src_line = src.replace(',', ',')
    tgt_line = tgt.replace(',', ',')
    _edits = Levenshtein.opcodes(src_line[::-1], tgt_line[::-1])[::-1]
    edits = []
    src_len = len(src_line)
    tgt_len = len(tgt_line)
    for edit in _edits:
        edits.append((edit[0], src_len - edit[2], src_len - edit[1], tgt_len - edit[4], tgt_len - edit[3]))
    merged_edits = []
    for edit in edits:
        if edit[0] == 'equal':
            continue
        if len(merged_edits) > 0:
            last_edit = merged_edits[-1]
            if last_edit[0] == 'insert' and edit[0] == 'insert' and last_edit[2] == edit[1]:
                new_edit = ('insert', last_edit[1], edit[2], last_edit[3], edit[4])
                merged_edits[-1] = new_edit
            elif last_edit[2] == edit[1]:
                assert last_edit[4] == edit[3]
                new_edit = ('hybrid', last_edit[1], edit[2], last_edit[3], edit[4])
                merged_edits[-1] = new_edit
            elif last_edit[0] == 'insert' and edit[0] == 'delete' \
                and tgt_line[last_edit[3]:last_edit[4]] == src_line[edit[1]:edit[2]]:
                new_edit = ('luanxu', last_edit[1], edit[2], last_edit[3], edit[4])
                merged_edits[-1] = new_edit
            elif last_edit[0] == 'delete' and edit[0] == 'insert':
                if src_line[last_edit[1]:last_edit[2]] == tgt_line[edit[3]:edit[4]]:
                    new_edit = ('luanxu', last_edit[1], edit[2], last_edit[3], edit[4])
                    merged_edits[-1] = new_edit
                elif edit[4] < len(tgt_line) and tgt_line[edit[3]] == tgt_line[edit[4]] and src_line[last_edit[1]:last_edit[2]] == tgt_line[edit[3]+1:edit[4]+1]:
                    new_edit = ('luanxu', last_edit[1], edit[2]+1, last_edit[3], edit[4])
                    merged_edits[-1] = new_edit
                else:
                    merged_edits.append(edit)
            else:
                merged_edits.append(edit)
        else:
            merged_edits.append(edit)
    merged_edits2 = []
    for edit in merged_edits:
        if edit[0] == 'equal':
            continue
        if len(merged_edits2) > 0:
            last_edit = merged_edits2[-1]
            if last_edit[0] == 'insert' and edit[0] == 'insert' and last_edit[2] == edit[1]:
                new_edit = ('insert', last_edit[1], edit[2], last_edit[3], edit[4])
                merged_edits2[-1] = new_edit
            elif last_edit[2] == edit[1]:
                assert last_edit[4] == edit[3]
                new_edit = ('hybrid', last_edit[1], edit[2], last_edit[3], edit[4])
                merged_edits2[-1] = new_edit
            elif last_edit[0] == 'insert' and edit[0] == 'delete' \
                and tgt_line[last_edit[3]:last_edit[4]] == src_line[edit[1]:edit[2]]:
                new_edit = ('luanxu', last_edit[1], edit[2], last_edit[3], edit[4])
                merged_edits2[-1] = new_edit
            elif last_edit[0] == 'delete' and edit[0] == 'insert':
                if src_line[last_edit[1]:last_edit[2]] == tgt_line[edit[3]:edit[4]]:
                    new_edit = ('luanxu', last_edit[1], edit[2], last_edit[3], edit[4])
                    merged_edits2[-1] = new_edit
                elif edit[4] < len(tgt_line) and tgt_line[edit[3]] == tgt_line[edit[4]] and src_line[last_edit[1]:last_edit[2]] == tgt_line[edit[3]+1:edit[4]+1]:
                    new_edit = ('luanxu', last_edit[1], edit[2]+1, last_edit[3], edit[4])
                    merged_edits2[-1] = new_edit
                else:
                    merged_edits2.append(edit)
            else:
                merged_edits2.append(edit)
        else:
            merged_edits2.append(edit)
    result = []
    for edit in merged_edits2:
        if tgt_line[edit[3]:edit[4]] == '[UNK]':
            continue
        if edit[0] == "insert":
            result.append((str(edit[1]+1), str(edit[1]+1), "insert", tgt_line[edit[3]:edit[4]]))
        elif edit[0] == "replace":
            result.append((str(edit[1]+1), str(edit[2]), "replace", tgt_line[edit[3]:edit[4]]))
        elif edit[0] == "delete":
            result.append((str(edit[1]+1), str(edit[2]), "delete"))
        elif edit[0] == "hybrid":
            result.append((str(edit[1]+1), str(edit[2]), "hybrid", tgt_line[edit[3]:edit[4]]))
        elif edit[0] == "luanxu":
            result.append((str(edit[1]+1), str(edit[2]) , "luanxu"))
    return result

纠错接口设计

​ 这里采用flask框架设计接口

from flask import Flask,request,redirect
import json
import Levenshtein
from modelscope.pipelines import pipeline
from modelscope.utils.constant import Tasks
from pycorrector.macbert.macbert_corrector import MacBertCorrector


app=Flask(__name__)

model_id = 'damo/nlp_bart_text-error-correction_chinese'
pipeline = pipeline(Tasks.text_error_correction, model=model_id)
m = MacBertCorrector()


@app.route("/")
def home():
    return redirect("/static/index.html")


@app.route("/getcorrection",methods=["POST"])
def check():
    srctext = request.get_data()
    srctext = str(srctext, 'utf-8')
    result_list = get_textlist(srctext)
    ans = ""
    for seq in result_list:
        if seq == '':
            continue
        if len(seq) > 100 or len(seq) <= 2:
            ans += seq
        else:
            result = pipeline(seq)['output']
            correct_sent, err = m.macbert_correct(result)
            if len(correct_sent) != len(result):
                correct_sent = result
            result = correct_sent
            ans += result
    return get_corrlist(srctext,ans)

if __name__ == "__main__":
    app.run(host='0.0.0.0',port=8080,debug=True)

​ 最终的接口效果如下:

接口效果展示

后记

​ 虽然接口完成后我的本职工作就完成了,不过为了能更好地利用这台服务器,自己也独立地稍微做一些前端,写了个简单的页面。点此跳转

​ 因为双模型下对性能消耗过大,所以该页面暂时就只使用Macbert单模型进行纠错。

​ 至此,关于这个项目的研究也告一段落了。