SequenceMatcher: Python 字符串序列处理速效救心丸

引言

最近工作偶尔会跟 NER 模型(命名实体识别)打交道,简单介绍一下背景,NER 模型的输出是一个结构化数据,比如:

// 万达广场沙县小吃
{
    "slot": "沙县小吃",
    "start": "4",
    "end": "8",
    "entity": "商铺"
}

在交付给业务方之前,需要使用 BIO 方案将原字符串序列转换成一个带命名实体标签的字符串序列:

万达广场沙县小吃
=>
万_O 达_O 广_O 场_O 沙_B-商铺 县_I-商铺 小_I-商铺 吃_I-商铺

如果事情到这里就结束了,那简直皆大欢喜,卷完下班。然而,在实践中,业务方们那儿反馈了两个问题,直接「被迫」加班。

问题一:模型预测的结果里,为啥会有 [UNK]_O 这样的字符?没法看好不啦…

一个业务方要的是一个古文领域的 NER 模型,不难想象,总是存在一部分生僻的汉字字符。熟悉 BERT 的同学都知道,无法识别的汉字字符,在数据预处理阶段,我们习惯上会直接拿一个特殊的 [UNK] 替代它。这也就意味着,模型预测结果,自然而然地存在如问题描述中所示的字符:[UNK]_O

问题二:原来的文本序列包含空格的,为啥预测的序列内,空格被吞了?这跟标注的序列一样又不一样,脑壳疼…

你可能会问,那如果数据集不是古文,而是常用的现代汉语数据集,那应该不会出现问题一了吧?当然,然而故事永远不会这么简单。另一个业务方反馈了问题二,经过一番排查,发现是模型在预测输入文本之前,会对待预测文本作分词处理,再以空格为分隔符重新合并成一个字符串,方便后续处理,比如:

重庆冰淇淋 红豆冰粉
=>
重庆 冰淇淋 红豆 冰粉

如此一来,空格就被吞掉了…不管是问题一还是问题二,如果只在乎被识别的命名实体,那其实无伤大雅。不过业务方的态度出奇得一致,纷纷表示这样不行,预测序列除开标签,必须与预测文本完全一致。

基本思路

看到问题一,我们很自然地想到了通过遍历的方式对两个序列进行比对,遇到同一位置不一致的字符(特指其中一个字符是 [UNK])时,替代即可。于是我们闭着眼睛,一气呵成:

def replace_unk(text: str, sequence: str) -> str:
    characters = [word for word in text]
    tokens, tags = (
        list(map(lambda item: item.split("_")[0], sequence.split())),
        list(map(lambda item: item.split("_")[1], sequence.split())), 
    )
    
    for i in range(len(characters)):
        if characters[i] == tokens[i]:
            continue
        tokens[i] = characters[i]

    return " ".join([f"{token}_{tag}" for token, tag in zip(tokens, tags)])

运行,下…等等,有问题,上述方案的基础是两个序列的长度一致,不过这个基础却是相当薄弱的。这里涉及到一个有关汉字字符集的知识。当我们使用 UTF-8 对汉字字符进行编码时,通常情况下,一个汉字字符使用 3 字节编码,但这并不意味着所有汉字均是 3 字节编码。

要知道,包括我们常用的汉字在内,汉字的数量总计有八九万之多,存在部分生僻字使用了 4 字节编码。当我们使用列表生成式将一个字符串序列转换成一个字符串列表,若是存在 4 字节编码的汉字,就会被拆掉。在这种情况下,text 的长度就会比 sequence 的长度至少大 1,于是运行上述代码,哦吼,喜提 IndexError

至于问题二,用上述方法更是没办法解决。由于空格的村砸,text 的长度天然就比 sequence 大,基本没办法运行。这时我们可能会想到使用双指针等方案,总之就是一顿比较,使劲让两个序列对齐。沿着这条路走下去,我们就会遇到数不清的边缘 Case…其实也数得清,问题是我们在加班啊,怎么可以这么陷进去!必须想想其他办法!

终极方案

好在时代变了,终究不再是遇到问题一边谷歌一边苦思冥想的年代了。简单整理一下问题,抛给 GPT-4,让 AI 给我们想办法。这一抛不要紧,AI 给出了一个基于 Python 原生 difflib 库的方案,就是本文的主角 SequenceMatcher

定义

关于 SequenceMatcher 的定义,Python 官方文档的解释是这样的:

This is a flexible class for comparing pairs of sequences of any type, so long as the sequence elements are hashable…The idea is to find the longest contiguous matching subsequence that contains no “junk” elements; these “junk” elements are ones that are uninteresting in some sense, such as blank lines and whitespaces.

从官方解释来看,SequenceMatcher 类的作用就是比较序列对,从中找出最长公共子序列,且其内部不包含一些「无用」元素,比如说空行或者空行等。这完美解决我们所遇到的问题二,问题一也不是问题。

基本使用

首先,我们可以从一个简单的示例开始,逐步了解 SequenceMatcher 类的基本使用方式:

from difflib import SequenceMatcher

string1, string2 = "早安", "晚安"

# 创建 SequenceMatcher 实例
matcher = SequenceMatcher(None, string1, string2)

# 获取相似度百分比
similarity = matcher.ratio()

print(f"相似度:{similarity}")  # 相似度:0.5

如上所示,我们需要将两个待比较的序列作为参数传入 SequenceMatcher 类,创建相应的对象实例,其签名如下所示:

class SequenceMatcher(isjunk=None, a="", b="", autojunk=True)

isjunk 参数取值为 None 时,表示不明确指定「无用」元素,由对象实例自行发现和处理;若是想要明确规定某些元素为「元素」,则可以传入一个 Lambda 表示式。听起来比较晦涩,我们可以通过几个例子来仔细体会它的作用。

import difflib

source, target = 'Hello World', 'HEllO wOrld'

matcher = difflib.SequenceMatcher(isjunk=None, a=source, b=target)

首先,我们创建一个 matcher 实例,其 isjunk 参数取值为 None,我们可以看一下两个字符串序列的比较结果:

for tag, i1, i2, j1, j2 in matcher.get_opcodes():
    if tag == 'equal':
        print("相同部分:", seq1[i1:i2])
    elif tag == 'delete':
        print("在seq1中删除的部分:", seq1[i1:i2])
    elif tag == 'insert':
        print("在seq2中新增的部分:", seq2[j1:j2])
    elif tag == 'replace':
        print("在seq1中替换为seq2的部分:", seq1[i1:i2], "=>", seq2[j1:j2])

'''
相同部分: H
在seq1中替换为seq2的部分: e => E
相同部分: ll
在seq1中替换为seq2的部分: o => O
相同部分: 
在seq1中替换为seq2的部分: Wo => wO
相同部分: rld
'''

不难发现,两个序列之间的比较是一一进行的,不会忽视任何字符。此时,若是我们希望忽视小写字母呢:

matcher = difflib.SequenceMatcher(isjunk=lambda x: x.islower(), a=source, b=target)

此时再做比较,就会发现输出变了,小写字母直接被当成了所谓的 “junk” 元素,是需要被替换或删除的存在:

相同部分: H
在seq1中替换为seq2的部分: ello => EllO
相同部分: 
在seq1中替换为seq2的部分: World => wOrld

在上述例子中,我们分别接触到了 SequenceMatcher 类的 ratio 方法和 get_opcodes 方法,前者用于计算两个序列之间的相似度,计算方式十分简单:

Sa,b=FsFd+FsS_{a, b} = \frac{F_s}{F_d + F_s} Sa,b​=Fd​+Fs​Fs​​

其中,Sa,bS_{a, b}Sa,b​ 表示两个序列之间的相似度,而 FsF_sFs​ 表示相同的元素数量,FdF_dFd​ 表示相异的元素数量。至于后者,其返回值描述了如何从序列 A 转换成序列B。该函数的签名如下所示:

get_opcodes() -> List[Tuple[str, int, int, int, int]]

可见,函数 get_opcodes 返回一个元组列表,每个元组的第一个元素描述两个子序列之间的关系,其他元素则分别表示两个被比较的子序列起始和末尾。两个序列之间的转换,存在四种关系:

ValueMeaning
'replace'a[i1:i2] 能被 b[j1:j2] 替换。
'delete'a[i1:i2] 需被删除,注意此时 j1 == j2
'insert'b[j1:j2] 需要插入到 a[i1:i1] 的位置,注意此时 i1 == i2
'equal'a[i1:i2] == b[j1:j2] 即两个字序列相同。

可以说,通过上述关系,我们能够很好的解决 NER 模型预测所遇到的问题一和问题二:

def replace_unk(text: str, sequence: str) -> str:
    characters = [word for word in text]
    tokens, tags = (
        list(map(lambda item: item.split("_")[0], sequence.split())),
        list(map(lambda item: item.split("_")[1], sequence.split())), 
    )
    
    matcher = difflib.SequenceMatcher(None, characters, tokens)
    for action, left_start, left_end, right_start, right_end in reversed(matcher.get_opcodes()):
        # 解决问题一
        if action == "replace":
            tokens[right_start:right_end] = characters[left_start:left_end]

        # 解决问题二
        if action == "delete":
            tokens[right_start:right_end] = characters[left_start:left_end]
            # 复原空格的同时,需要在对应位置新增标签
            tags[right_start:right_end] = ["O"] * (left_end - left_start)

    return " ".join([f"{token}_{tag}" for token, tag in zip(tokens, tags)])

结语

借助 SequenceMatcher 类,我们能够无视各种边缘 Case,快速且优雅地解决了两个问题。其存在一个重要前提,那便是两个序列虽有不同,但整体是类似的。面对更加复杂的场景,我们需要思考得更多,这就不是今天需要考虑的问题了。好了,问题解决,下班!

---------------------------END---------------------------

题外话

感谢你能看到最后,给大家准备了一些福利!

感兴趣的小伙伴,赠送全套Python学习资料,包含面试题、简历资料等具体看下方。


👉CSDN大礼包🎁:全网最全《Python学习资料》免费赠送🆓!(安全链接,放心点击)

一、Python所有方向的学习路线

Python所有方向的技术点做的整理,形成各个领域的知识点汇总,它的用处就在于,你可以按照下面的知识点去找对应的学习资源,保证自己学得较为全面。

img

二、Python兼职渠道推荐*

学的同时助你创收,每天花1-2小时兼职,轻松稿定生活费.
在这里插入图片描述

三、最新Python学习笔记

当我学到一定基础,有自己的理解能力的时候,会去阅读一些前辈整理的书籍或者手写的笔记资料,这些笔记详细记载了他们对一些技术点的理解,这些理解是比较独到,可以学到不一样的思路。

img

四、实战案例

纸上得来终觉浅,要学会跟着视频一起敲,要动手实操,才能将自己的所学运用到实际当中去,这时候可以搞点实战案例来学习。

img

👉CSDN大礼包🎁:全网最全《Python学习资料》免费赠送🆓!(安全链接,放心点击)

若有侵权,请联系删除

  • 25
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值