HanLP2.1 用户自定义词典 ./hanlp_trie/trie.py parse_longest()只返回value,需要返回key与value

小何博士好,
好久没见,最近换了个联想拯救者Y9000X 2021笔记本,带NVIDIA GeForce RTX 2060 Max-Q GPU,就装起HanLP2.1测试一下。发觉 trie.py的parse_longest()函数只返回value:

    def parse_longest(self, text: Sequence[str]) -> List[Tuple[int, int, Any]]:
        """Longest-prefix-matching which tries to match the longest keyword sequentially from the head of the text till
        its tail. By definition, the matches won't overlap with each other.

        Args:
            text: A piece of text. In HanLP's design, it doesn't really matter whether this is a str or a list of str.
                The trie will transit on either types properly, which means a list of str simply defines a list of
                transition criteria while a str defines each criterion as a character.

        Returns:
            A tuple of ``(begin, end, value)``.

        """
        found = []
        i = 0
        while i < len(text):
            state = self.transit(text[i])
            if state:
                to = i + 1
                end = to
                value = state._value
                for to in range(i + 1, len(text)):
                    state = state.transit(text[to])
                    if not state:
                        break
                    if state._value is not None:
                        value = state._value
                        end = to + 1
                if value is not None:
                    found.append((i, end, value))
                    i = end - 1
            i += 1
        return found

而我在发票货物劳务名称识别的落地应用研究中,大量货物劳务的专有名称需要通过用户自定义词典识别,分词与词性标注后,建立语法树与语义图,然后根据分词与词性标注的结果编写算法提取货物劳务名称。因此需要返回key与value(词/词性),我用了HanLP2.0中相应的函数,改名区别:

    # Added by Jean for returning key and value together
    def parse_longest2(self, text: Sequence[str]) -> List[Tuple[Union[str, Sequence[str]], Any, int, int]]:
        found = []
        i = 0
        while i < len(text):
            state = self.transit(text[i])
            if state:
                to = i + 1
                end = to
                value = state._value
                for to in range(i + 1, len(text)):
                    state = state.transit(text[to])
                    if not state:
                        break
                    if state._value is not None:
                        value = state._value
                        end = to + 1
                if value is not None:
                    found.append((text[i:end], value, i, end))
                    i = end - 1
            i += 1
        return found

我觉得很多人应该有相似的需求,这是个有普遍性的需求。希望HanLP2.1后续的版本中可以合并源码提供这样的支持,谢谢!
最近半年主要在研究发票交易网络分析,所以推迟了对HanLP2.1的测试。从HanLP2.0升级到2.1,一些API稍有不同,花了2天定位错误才跑通了GPU并行分词与词性标注等的测试实例,还好。8-)

1 Like

自定义词典样本:

~~,W
线路板,NN
安装费,NN
服务费,NN
接插件,NN
住宿服务,NN
印刷电路板,NN

其中第一行“~~,W”是为GPU并行分词保留的句子分隔符。
发票汇总数据例子,第一列是开票的货物劳务名称,第二列是票的数量,第三列是合计金额:

(详见销货清单)~373268~185622721226.4969
住宿费~334968~580953368.6700417
线路板~252675~3341652776.9199977
电费~143521~7424638904.16005
安装费~139159~6573876237.931455
接插件~133242~652331610.3000057
硒鼓~126987~1293772672.3599849
住宿服务~90776~492051551.4299693
印刷电路板~84094~1848108565.500004
港口装卸费~80732~27682221.530001897
维修费~78036~534169884.0099971
服务费~75033~5048165488.219012
纸箱~73592~948871944.979978

详见另一帖子:

请问HanLP2.0怎样可以在pipline中使用用户自定义词典后仍然支持在GPU上并行?

返回值是(begin, end, value)包含了key的起止下标,你可以通过text[begin:end]得到相应的key。

另外2.1已经支持了用户词典,不再需要用户自己建trie了:

目前还不支持自定义词性,不过这个需求可以通过trie.get甚至dict.get自行实现。

既然升级到了2.1,大多数场景建议不要再用2.0的pipeline了。你如果用2.1的MTL,一切都是并行的。2.1还带来了新功能新语种,参考:https://play.hanlp.ml/

我这项研究主要是提供一个落地应用的概念模型,解决瓶颈问题,探索一条可行的技术路线,说明问题可以这样解决,具体生产环境的开发部署等问题是不能不应也不想去卷入的。之前考察过BATHK的NLP云平台,不能同时满足我的要求,也都分别沟通过,最后发现HanLP解决了问题。所以HanLP2.0的trie用户自定义词典与pipline解决了问题,首先至少有了一个可行解决方案。 刚才跑了2.1的demo,加载用户自定义词典与批量分词与标注词性是方便了不少:

import hanlp
from hanlp.components.mtl.multi_task_learning import MultiTaskLearning
from hanlp.components.mtl.tasks.tok.tag_tok import TaggingTokenization
from tests import cdroot

cdroot()
HanLP: MultiTaskLearning = hanlp.load(hanlp.pretrained.mtl.CLOSE_TOK_POS_NER_SRL_DEP_SDP_CON_ELECTRA_SMALL_ZH)
tok: TaggingTokenization = HanLP['tok/fine']

tok.dict_force = tok.dict_combine = None
print(f'不挂词典:\n{HanLP("商品和服务行业")["tok/fine"]}')

tok.dict_force = {'和服', '服务行业'}
print(f'强制模式:\n{HanLP("商品和服务行业")["tok/fine"]}')  # 慎用,详见《自然语言处理入门》第二章

tok.dict_force = {'和服务': ['和', '服务']}
print(f'强制校正:\n{HanLP("正向匹配商品和服务、任何和服务必按上述切分")["tok/fine"]}')

tok.dict_force = None
tok.dict_combine = {'和服', '服务行业'}
print(f'合并模式:\n{HanLP("商品和服务行业")["tok/fine"]}')

import hanlp
from hanlp_common.document import Document

HanLP = hanlp.load(hanlp.pretrained.mtl.CLOSE_TOK_POS_NER_SRL_DEP_SDP_CON_ELECTRA_BASE_ZH)
doc: Document = HanLP(['2021年HanLPv2.1为生产环境带来次世代最先进的多语种NLP技术。', '阿婆主来到北京立方庭参观自然语义科技公司。'])
print(doc)
doc.pretty_print()
# Specify which annotation to use
# doc.pretty_print(ner='ner/ontonotes', pos='pku')

我的解决方案必须支持输出分词的词性,因为后面提取货物劳务名称的算法中,需要根据语法树或语义图提取上层名词性的分词作为货物劳务名称输出。所以在HanLP2.1上,怎样在tok中支持输出词性,还请小何博士指导一下,我对NLP及HanLP底层的东西一点都不了解。谢谢!

附:初步提取算法,用igraph在内存中建立语法树或语义图,然后遍历提取,每个结点都带词性。

import time, math, random
import pandas as pd
import collections
import json,re
from igraph import *
import hanlp
# from hanlp.common.trie import Trie  # HanLP2.0 -->2.1
from hanlp_trie.trie import Trie

# ......

# 建立语法树(图)/语义网络(图)函数, 语义分析结果为一个有向无环图,称为语义依存图(Semantic Dependency Graph)。
def getTree(i,text):
    res = json.loads(text)
    try:
        items = res["items"]
    except KeyError:
        print(i,text)
        return None
    ids = []; pos = []; words = []; head = []; deprel = []
    for item in items:
        ids.append(item["id"]); words.append(item["form"]); 
        # pos.append(item["cpos"])     # HanLP2.0 -->2.1
        try:
            pos.append(item["upos"])   # 语法树(图)parser1,3,4,多数
        except KeyError:
            pos.append(item["cpos"])   # 语义树(图)parser2,少数
        head.append(item["head"]); deprel.append(item["deprel"])
    vs = pd.DataFrame({"id":ids,"word":words,"postag":pos})
    # 存在一对多的情况,每个对应关系展开成一条边。 head与deprel为list。
    try: 
        ids2 = []; head2 = []; deprel2 = []
        for i in range(len(ids)):
            for j in range(len(head[i])):
                ids2.append(ids[i]); head2.append(head[i][j]); deprel2.append(deprel[i][j])
        es = pd.DataFrame({"id":ids2,"head":head2,"deprel":deprel2})
    # 没有一对多的情况,不展开
    except Exception:
        es = pd.DataFrame({"id":ids,"head":head,"deprel":deprel})
    vd = vs.to_dict(orient='records')
    ed = es.to_dict(orient='records')
    g = Graph.DictList(vd,ed, vertex_name_attr="id",edge_foreign_keys=('id', 'head'),directed=True)
    # 给根节点赋非空值
    vs = g.vs.select(id=0)   
    vs["word"] = ""; vs["postag"] = ""
    g.vs["label"]=[str(v.index)+":"+v["word"]+"\n"+v["postag"] for v in g.vs]
    g.es["label"]=g.es["deprel"]
    return g

# 从根结点开始遍历语法树,取最先出现的名词作货劳名称,所以层级高的优先,同层级的在后面的优先
def get_hlmc(g, verbose = False):
    # 建立邻接表
    adj = [[n.index for n in v.neighbors("in")] for v in g.vs]
    spcialWords = re.compile("[\.|\-|%]") #特殊名称, "."软件版本号, "-"具体型号,跳过
    spcialPuncs = ["-", "/", "*", "+"]       #特殊标点符号,标示里面包含并列关系,
    skipWords = re.compile("[顶|装]")
    # 广度优先搜索遍历语法树
    def traverse(adj, paths, depth):
        # 记录遍历的深度
        depth += 1;  nxt_paths = []
        # 遍历所有的路径
        for path in paths['paths']:
            v = g.vs[path[-1]]
            # 如果不是并列词,则反转邻接表,改成同层级的后面的优先,排在后面的名词优先,
            # 发票语法结构的特点,前面的名词多数是修饰语         
            if v["word"] not in spcialPuncs and depth>1:
                adj[path[-1]].reverse()
            for idx in adj[path[-1]]:
                v1 = g.vs[idx]
                # 如 汤臣倍健多种维生素咀嚼片-迪士尼漫威装, 根结点是 漫威装
                if v1["word"] in spcialPuncs:
                    adj[path[-1]].reverse()  # 子结点中有并列关系,邻接表恢复为正常顺序。                    
                    break
            # 遍历路径中最后一个结点的所有邻居
            for nxt in adj[path[-1]]:
                v = g.vs[nxt]
                if verbose:
                    print(depth,v["word"],v["postag"])
                if "N"  in v["postag"]:   # 如果是名词,则加入候选货劳清单中
                    flag = True
                    if spcialWords.findall(v["word"]) and v["postag"]!= "NM" :  # 软件版本号,具体型号,跳过,药名除外。
                        flag = False
                    if v["postag"]== "NT":  # 品牌,跳过
                        flag = False
                    matched = skipWords.search(v["word"])   # 检查是否包含要跳过的特殊名词
                    if matched:
                        if matched.span()[0]+1 == len(v["word"]):   # 漫威装 在最后,跳过;安装费 在中间,接受
                            flag = False
                    if flag:
                        paths["hlmc"].append(v["word"])
                        paths["pos"].append(v["postag"])
                nxt_path = path + [nxt]   # 在路径中加入下一个结点,生成新的路径
                nxt_paths.append(nxt_path)
        # 路径的深度增加了一级
        paths['paths'] = nxt_paths                            
        if len(nxt_paths)==0:
            # 没有新路径了
            return paths
        else:
            # 往路径深度递归调用一级
            return traverse(adj, paths, depth)
    # 从根结点开始递归遍历语法树
    v = g.vs.select(id=0)[0]
    # 返回候选货劳名称列表,排在前面的在语法树中的层级较高,同层级中则排在较后的优先级较高
    res = traverse(adj, {'paths': [[v.index]], 'hlmc': [], "pos": []}, 0)
    # 没有找到名词,返回一级结点作货劳名称
    if len(res["hlmc"]) == 0:
        v1 = v.neighbors("in")[0]
        res["hlmc"].append(v1["word"]); res["pos"].append(v1["postag"])
    
    return res

# ......

tok不负责词性。你在tok的基础上查一下dict不就行了?

我对HanLP及tok的内在都不了解,目前也有其它优先的事项要处理。
我的目标只是提出一个可行的概念模型,说明此路可通,所以虽然查字典可能很简单,但暂时未有精力去实现tok上输出词性的自定义词典。
补充一下发票货劳名称文本预处理的函数,预处理后再分词、词性标注、语法分析、语义分析,准确度会高很多,这样解决方案就比较完整了。

# 句法依存分析前对汇总后的货物劳务名称做预处理
def preprocess(row):     
    text = row["hwmc"]
    # 去掉前后空格等特殊处理
    text = text.strip()
    if text == "." or text == "/"  or text == "-":
        text = ""    
    # 去掉几种括号及其中内容,如果去掉后没有内容了,则予以保留
    text1 = text
    text = re.sub("\\(.*?\\)|(.*?)", "", text)
    if len(text) == 0:
        text = text1
    text1 = text
    text = re.sub("\\[.*?\\]|【.*?】", "", text)
    if len(text) == 0:
        text = text1
    text1 = text
    text = re.sub("\\{.*?\\}|{.*?}", "", text)
    if len(text) == 0:
        text = text1
    # 去掉商品及货物分类简称
    text1 = text        
    text = re.sub("\\*.*?\\*", "", text)
    if len(text) == 0:
        text = text1
    # 去掉单边括号后的内容,这种情况不少
    text1 = text        
    text = text.split("(")[0]  #	722 维达(Vinda 	822	荣耀8全网通(FRD 	825	彩电挂式安装调试卡(
    if len(text) == 0:
        text = text1
    text1 = text                
    text = text.split(")")[0] 
    if len(text) == 0:
        text = text1  
    text1 = text                
    text = text.split("(")[0]  
    if len(text) == 0:
        text = text1  
    text1 = text                
    text = text.split(")")[0]  
    if len(text) == 0:
        text = text1  
    text1 = text
    text = re.sub("及", " ", text)  # 国际快件费及附加 -> 国际快件费 附加
    if len(text) == 0:
        text = text1        
    text1 = text                
    text = text.split(" ")[0]  # 空格分隔的多个并列,只留第一个,如 "小米5C 移动版 3"
    if len(text) == 0:
        text = text1        
    text = re.sub("[0-9]L", "", text)  # 胶瓶美汁源果粒橙12*1.25L,去掉L,引起分词错误 
    
    return text