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

引言

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

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

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

text 复制代码
万达广场沙县小吃
=>
万_O 达_O 广_O 场_O 沙_B-商铺 县_I-商铺 小_I-商铺 吃_I-商铺

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

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

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

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

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

text 复制代码
重庆冰淇淋 红豆冰粉
=>
重庆 冰淇淋 红豆 冰粉

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

基本思路

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

python 复制代码
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 类的基本使用方式:

python 复制代码
from difflib import SequenceMatcher

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

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

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

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

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

python 复制代码
class SequenceMatcher(isjunk=None, a="", b="", autojunk=True)

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

python 复制代码
import difflib

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

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

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

python 复制代码
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
'''

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

python 复制代码
matcher = difflib.SequenceMatcher(isjunk=lambda x: x.islower(), a=source, b=target)

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

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

在上述例子中,我们分别接触到了 SequenceMatcher 类的 ratio 方法和 get_opcodes 方法,前者用于计算两个序列之间的相似度,计算方式十分简单:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> S a , b = F s F d + F s S_{a, b} = \frac{F_s}{F_d + F_s} </math>Sa,b=Fd+FsFs

其中, <math xmlns="http://www.w3.org/1998/Math/MathML"> S a , b S_{a, b} </math>Sa,b 表示两个序列之间的相似度,而 <math xmlns="http://www.w3.org/1998/Math/MathML"> F s F_s </math>Fs 表示相同的元素数量, <math xmlns="http://www.w3.org/1998/Math/MathML"> F d F_d </math>Fd 表示相异的元素数量。至于后者,其返回值描述了如何从序列 A 转换成序列B。该函数的签名如下所示:

python 复制代码
get_opcodes() -> List[Tuple[str, int, int, int, int]]

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

Value Meaning
'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 模型预测所遇到的问题一和问题二:

python 复制代码
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,快速且优雅地解决了两个问题。其存在一个重要前提,那便是两个序列虽有不同,但整体是类似的。面对更加复杂的场景,我们需要思考得更多,这就不是今天需要考虑的问题了。好了,问题解决,下班!

相关推荐
思忖小下22 分钟前
Python基础学习-11函数参数
python·语法
ᝰꫝꪉꪯꫀ3611 小时前
JavaWeb——Maven高级
java·后端·maven·springboot
封步宇AIGC2 小时前
量化交易系统开发-实时行情自动化交易-4.4.1.做市策略实现
人工智能·python·机器学习·数据挖掘
Uluoyu2 小时前
python多线程使用rabbitmq
python·rabbitmq·ruby
q0_0p4 小时前
从零开始的Python世界生活——基础篇(Python字典)
python·python基础
databook4 小时前
manim边做边学--圆柱体
python·动效
deephub4 小时前
Scikit-learn Pipeline完全指南:高效构建机器学习工作流
人工智能·python·机器学习·scikit-learn
麻衣带我去上学4 小时前
Pytest使用Jpype调用jar包报错:Windows fatal exception: access violation
windows·python·pytest·jar
轩情吖4 小时前
模拟实现Bash
linux·c语言·开发语言·c++·后端·bash·环境变量
李昊哲小课5 小时前
springboot整合hive
大数据·数据仓库·hive·spring boot·后端·数据分析