为什么 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 必须限制在句内,需要先确定句子边界,再分别生成。
备注:内容仅供参考。
