PAYATHON 2026

为什么 Python tokenization 在多次运行后得到的 tokens 数量不一致?

排障赵师傅

结果不一致是因为 list(set(all_punc)) 中的 set 不保证迭代顺序,而后面的循环遇到第一个匹配的标点就执行 break。标点的遍历顺序一变,同一个字符串就可能从不同位置拆开,得到的 token 数量也可能不同。

list 会保留创建时接收到的元素顺序,str.partition() 对相同参数也会返回相同结果。

为什么标点顺序会变化?

这行代码通过集合去重,再把结果转成列表:

unique_punc = list(set(all_punc))

set(all_punc) 去掉了重复字符,却不保留它们在文本中首次出现的顺序。外层的 list() 按集合当时的迭代顺序收集元素。

Python 环境通常启用字符串哈希随机化,不同进程中的字符串哈希值可能不同,进而影响集合的迭代顺序。因此,重新启动脚本后,puncs 的顺序可能变化,但不一定每次都变。同一进程中,一个未修改的集合也不会在每次遍历时重新打乱顺序。

后面的循环依赖这个顺序:

for p in puncs:
    if p in d:
        parted = d.partition(p)
        # 保存非空部分
        break

循环会选中 puncs 中最先遍历到且存在于 d 中的标点。这个标点在字符串中的位置不一定最靠前。

为什么顺序变化会影响 token 数量?

str.partition(sep) 只在 sep 第一次出现的位置拆分,返回一个三元组:

(分隔符之前的内容, 分隔符, 分隔符之后的内容)

以问题中的字符串为例:

text = "ta'kul,"

print(text.partition("'"))
# ('ta', "'", 'kul,')

print(text.partition(","))
# ("ta'kul", ',', '')

先检查单引号,过滤空字符串后会得到 3 个 tokens:

["ta", "'", "kul,"]

先检查逗号,则会得到 2 个:

["ta'kul", ","]

匹配后立即执行 break,剩余片段里的标点就不会继续拆分,因此文件内容和 token 总数都可能出现差异。前面的 data.split() 只按空白拆分,没有使用 puncs,它的计数不受标点遍历顺序影响。

最小修改:固定标点遍历顺序

如果只想让现有算法在相同输入下得到一致结果,可以对去重后的标点排序:

import string


def find_all_punctuations(text):
    return sorted({char for char in text if char in string.punctuation})

排序后,puncs 的遍历顺序就固定了。不过,原循环仍然只调用一次 partition(),所以这项修改只解决结果可重复的问题。像 kul, 中的逗号或 hello!!! 中剩余的感叹号,仍可能留在片段里。

完整分离标点:直接按规则提取 tokens

如果需要把每个标点字符都作为独立 token,可以直接扫描全文,省去先收集标点、再逐个尝试 partition() 的步骤:

import re
import string


punctuation = re.escape(string.punctuation)
token_pattern = re.compile(
    rf"[{punctuation}]|[^\s{punctuation}]+"
)


def tokenize(text):
    return token_pattern.findall(text.lower())


def get_all_bigrams():
    # encoding 应与 data.txt 的实际编码一致
    with open("data.txt", "r", encoding="utf-8") as dataset:
        text = dataset.read()

    tokens = tokenize(text)
    print("After punctuation separation:", len(tokens), "tokens")

    return list(zip(tokens, tokens[1:]))

正则表达式包含两种匹配:

  • [{punctuation}]:匹配一个标点字符。
  • [^\s{punctuation}]+:匹配连续的非空白、非指定标点字符。

re.escape() 会转义标点中的正则特殊字符,避免将它们当作表达式语法。

例如:

print(tokenize("ta'kul, Hello!!!"))
# ['ta', "'", 'kul', ',', 'hello', '!', '!', '!']

只要输入文本和拆分规则相同,这种写法就会按文本顺序得到一致的 tokens。

使用时需要注意什么?

string.punctuation 只包含一组 ASCII 标点,不包含中文逗号 ,、中文句号 。 或弯引号 ’。数据中如果有这些字符,需要扩展标点范围,例如:

punctuation = re.escape(string.punctuation + ",。!?;:“”‘’()")

这套规则也会拆开单词内部的撇号、小数点和连字符。例如,don't 会变成 ["don", "'", "t"],3.14 会变成 ["3", ".", "14"]。是否需要这样拆分,取决于你的 token 定义。

示例中的 zip(tokens, tokens[1:]) 会为整个 token 序列中所有相邻项生成 bigrams,其中也包括跨句子的相邻项。如果 bigrams 必须限制在句内,需要先确定句子边界,再分别生成。

备注:内容仅供参考。