☰
从20.9万韩国人名看合成persona数据集姓名分布与清洗校验
2026/10/28 11:10:55 网站建设 项目流程

做多语言合成人物数据集的人大多有过类似的经历:打开韩国那一批数据,姓氏那一列几乎被一个姓刷屏,翻到女性名字,又反复撞见同一个组合。我第一次看到 Nemotron-Personas-Korea 里那 20.9 万条韩国人名时,第一反应是分组统计写错了,第二反应是导出的文件被截断了,直到把原始字段一条条打印出来,才发现数字是对的,问题出在人名分布本身。人名看起来是数据集里最不值钱的字段,一个字符串而已,但它是 persona 的锚点,姓名称呼会渗透到对话模板、称谓系统、性别代词、职业描述,甚至影响下游评测的分数走向。这篇就把这 20.9 万条人名拆开看:为什么“金英淑”这类名字会高频冒头,姓氏集中在统计上意味着什么,罗马化拼写怎么把同一个人拆成三条记录,以及我在清洗和校验环节实际用过的脚本、参数和判断阈值。适合正在做合成数据、多语言评测集、个人信息脱敏或者单纯对姓名统计学好奇的人看,不需要韩语基础,代码部分有一点 pandas 经验就能跟下来。

1. 20.9万条人名背后:这份数据到底在生成什么

1.1 persona 数据集的结构决定了人名的角色

合成人物数据集的基本单元不是“人”,而是一条结构化的 persona 记录,典型字段包括唯一 id、姓名、性别、年龄、职业、所在地区、语言标签,再往下是一段自述式的 backstory。姓名在这个结构里承担两个功能:一是主键的语义化展现,方便人读;二是给下游模板提供称呼素材,比如“金女士”“李先生”这类称谓拼接。第二个功能才是真正麻烦的地方。称呼体系和性别绑定,性别又和名字强相关,一旦名字的性别倾向判断错了,后面整段对话里的称谓就会错位,而这种错位在人工抽检里非常隐蔽——因为大多数审核者不读韩语,看到“金英淑”和“她”搭配不会觉得有问题,看到“金英淑”和“他”搭配也可能一滑而过。

所以人名质量不是美观问题,是一致性问题。我在做这类数据集的验收时,习惯先把姓名、性别、年龄三列拉出来做交叉,任何一个组合出错,都会沿着 backstory 往下污染。20.9 万这个量级,靠人眼抽检是不现实的,只能靠规则加统计来定位异常。

1.2 20.9万这个数字意味着什么:组合空间与采样密度

先算一下采样密度,这是判断“高频是否异常”的前提。韩国姓氏高度集中,公开人口统计口径下,常见姓氏大约三十个就能覆盖九成以上人口,排名靠前的几个姓合计占比接近一半。名字部分,如果按两字名、每字从常用取名用字池里选,池子按三千字算,理论组合空间在两百万量级以上。也就是说,20.9 万条记录铺进一个几百万量级的组合空间,正常情况下单个全名的期望出现次数应该是个位数,Top 1 的名字占比不该超过万分之几。

实测下来,Top 1 全名的占比明显高于这个期望值,Top 100 全名吃掉的份额也远超均匀分布下的结果。这不是错误,而是分布本身的形状——真实人口里姓名分布本来就是幂律尾,少数组合覆盖大量人口。问题在于,合成数据的尾部比真实人口更陡。我在几个批次的样本上做过同一套统计,Top 10 全名占比稳定在真实基线的一点五倍到两倍之间,这个偏差就是“典型性偏好”留下的指纹。

1.3 “金英淑”成为标题的三个统计学理由

为什么偏偏是这个组合被拎出来当典型?我认为有三层原因叠在一起。

第一层是姓氏。“金”在韩国姓氏里长期占据第一位,占比在两成上下浮动,它出现在 Top 名字里几乎是必然的,任何一个人名列表的头名都大概率姓金。这不是模型偏好,是真实结构。

第二层是名字用字的世代集中。“英”和“淑”都是女性取名的高频字,尤其“淑”在上世纪中叶前后出生的女性名字里密度极高。合成模型在学分布时,会把这种跨世代的强信号压平成“整体高频”,导致这类用字在全部年龄段里均匀铺开,而不是集中在某个年龄段。结果就是二十岁的女性 persona 也叫“英淑”,六十岁的也叫“英淑”,整体频次被抬高了。

第三层是两字名的结构优势。单字名在韩国名字里占比不高,三字名更少,绝大多数是全名三个音节,即一姓加两字名。这个结构约束把组合空间压缩了一截,两字名中“高频字+高频字”的搭配自然更容易撞车。三层叠加,“金英淑”从这个分布里浮出来,几乎是数学上的必然,跟具体哪家模型关系不大。

2. 韩国姓名的结构拆解:一个姓少名多的系统

2.1 姓氏高度集中带来的统计假象

处理任何一个人名列表,第一步都该看姓氏分布,因为姓氏是最稳的结构信号。韩国的姓氏体系有个特点:姓的数量相对少,且集中度极高。几个大姓合计覆盖近半人口,前十大姓能吃掉七成左右。这个结构和我们熟悉的很多姓名体系都不一样,在那些体系里,前十大姓的覆盖率通常低得多。

这个差异会制造一种统计假象:如果你的数据集是按“全名”去重的,那么高频姓氏会自动把它的所有名字组合都抬进 Top 榜,看起来像是名字部分出了问题,其实是姓氏的基数效应。我一开始就踩过这个坑,对着 Top 100 名单盯了半天,以为是名字生成有偏,后来把姓名拆开,对姓氏和名字分别做频率统计,才发现姓氏部分的偏差很小,名字部分的偏差才是主要来源。

表格对比一下就更清楚,下面这组数字是我在样本上实测的相对占比,仅作结构示意,不是精确人口数据:

排名姓氏样本占比累计占比与公开基线的偏离
1金22.4%22.4%+1.5pt
2李15.1%37.5%+0.6pt
3朴9.2%46.7%+0.4pt
4崔5.0%51.7%+0.2pt
5郑4.6%56.3%+0.1pt
6-10其余大姓14.8%71.1%-0.3pt

看这张表,姓氏部分的偏离都在一两个百分点内,属于正常波动。真正需要警惕的是名字部分的偏离,那个量级会大得多。

2.2 名字用字的世代性与性别倾向

名字用字有强烈的世代性,这一点在东亚几个姓名体系里都成立。某些字在某个年代出生的人里密度极高,隔了三十年就几乎绝迹。这种世代波动是真实世界的规律,但如果合成模型只学到了“整体高频”,就会把世代信号抹掉,变成全年龄段均匀分布。

举个具体的例子:“淑”“子”“顺”这类字,在较早世代出生的女性名字里非常密集,在年轻世代里占比很低。而“지”“민”“서”“윤”这类音节在近二三十年出生的名字里占主导。如果数据集里一个 1998 年出生的女性 persona 叫“英淑”,从结构上看不出错,从世代上看是明显的错配。这种错配在单条记录里几乎无法判定,只有做年龄段交叉统计时才会暴露。

性别倾向是另一个维度。绝大多数取名用字有明确的性别倾向,但倾向强度差别很大:有的字九成以上用于女性,有的字接近中性。做校验时不能只用“是/否”的硬规则,得给每个字算一个性别倾向分值,用阈值来判。我一般把阈值设在 0.85,低于这个值的字不参与性别一致性判定,避免大量误报。

2.3 罗马化转写:同一个名字的多种面孔

这一节是我认为最容易被忽略、但影响最大的部分。韩文的罗马化转写有两套主流方案并存,再加上历史遗留的个人习惯拼法,同一个名字可能有四五种写法。姓氏层面就很明显:同一个姓,常见拼法有两三种,不同拼法在列表里会被当成不同的姓。

后果是什么?去重失效、频率统计被稀释、姓氏分布被拉平。你本来该看到“金”占 22%,结果因为拼法分散成了三个变体,每个只占 7%,排序直接乱掉。我第一版统计脚本就吃了这个亏,Top 1 姓氏统计出来是个不该出现在榜首的姓,排查了半天才发现是拼法拆分导致的。

下表是我整理的高频变体对照,做归一化映射时可以直接用:

标准转写常见变体归一化键备注
KimGim, Kim, Kymkim变体最多
LeeLee, Yi, Rhee, Rilee老式拼法常见
ParkPark, Pak, Bakpark按发音归一
ChoiChoi, Choe, Ch'oechoi撇号需剥离
JungJung, Jeong, Chungjeong三套方案分歧
KangKang, Gangkang一致性较好

归一化的核心原则是:按音位归并,不按字母归并。用编辑距离硬聚类会把 Lee 和 Rhee 这种同音异拼拆开,也会把两个不同的姓错误合并。稳妥的做法是维护一张人工确认过的映射表,表里一个键对应一组变体,脚本只做查表和剥离特殊字符,不做模糊匹配。

3. 从原始列表到可用数据集:清洗与校验的完整实操

3.1 环境和字段探查

我用的环境很朴素,Python 加 pandas,不需要额外依赖,跑 20 万行在普通笔记本上就是几秒的事。

python -m venv venv source venv/bin/activate pip install pandas numpy scipy

先做字段探查,别急着写统计逻辑。把字段名、类型、空值率、唯一值数量打出来,重点看姓名字段是不是有前后空格、有没有混入罗马化和本地文字两种写法、性别字段的取值集合是什么。

import pandas as pd df = pd.read_csv("personas_kr_sample.csv") print(df.shape) print(df.dtypes) print(df["name"].isna().sum(), df["name"].nunique()) print(df["gender"].value_counts(dropna=False)) print(df["name"].str.len().describe())

长度分布这一步很关键。全名是三个音节的话,本地文字写法长度应该集中在 3,如果有大量长度为 2 或者 5 的记录,说明混进了单字名、复姓,或者罗马化写法。我一般会把长度不等于 3 的记录单独捞出来看一遍,这批通常就是脏数据的富矿。

3.2 姓名切分与频率统计

切分逻辑看着简单,坑不少。单姓取首字,复姓要额外处理,罗马化写法要按连字符或空格拆。我的做法是先判断字符集,本地文字走一条路,拉丁字母走另一条路。

def split_name(raw: str): s = str(raw).strip() if not s: return None, None # 本地文字:按音节切 if "\uac00" <= s[0] <= "\ud7a3": compound = ("남궁", "황보", "제갈", "선우", "독고") if s[:2] in compound: return s[:2], s[2:] return s[0], s[1:] # 拉丁转写:先归一化再切 t = s.lower().replace("-", " ").replace("'", "") parts = t.split() if len(parts) >= 2: return parts[0], "".join(parts[1:]) return t, ""

切完之后做两张频率表,一张姓氏一张名字,都按降序排列并计算累计占比。这一步的输出直接决定了后面所有判断,所以务必把 Top 50 打出来人眼过一遍,能发现八成的问题。

df[["surname", "given"]] = df["name"].apply( lambda x: pd.Series(split_name(x)) ) surname_freq = df["surname"].value_counts(normalize=True) print(surname_freq.head(30)) print(surname_freq.head(10).sum()) # 前十大姓累计占比 given_freq = df["given"].value_counts(normalize=True) print(given_freq.head(50))

我的经验阈值是:前十大姓累计占比落在 65% 到 75% 之间算正常,低于 60% 大概率是拼法没归一化,高于 80% 大概率是生成时过度采样了头部姓氏。这两个方向的问题处理方式完全不同,所以必须先定位。

3.3 分布检验:是不是幂律

判断姓名分布是否健康,光看 Top N 不够,得看整条曲线的形状。姓名频率通常近似幂律,在双对数坐标下应该是一条斜率为负的直线。斜率太陡说明头部过度集中,斜率太缓说明分布被抹平了。

import numpy as np freqs = given_freq.values ranks = np.arange(1, len(freqs) + 1) mask = freqs > 0 slope, intercept = np.polyfit( np.log(ranks[mask]), np.log(freqs[mask]), 1 ) print(f"Zipf 斜率: {slope:.3f}")

参考区间我给一个实测经验值:这类姓名分布拟合出来的斜率通常在 -0.8 到 -1.2 之间。斜率低于 -1.4,说明头部几个名字被过度重复,典型原因是生成时的温度参数偏低,采样过于确定;斜率高于 -0.6,说明分布被拉平,典型原因是名称池混入了大量低频甚至不合法的组合。这套判断我在多个批次上验证过,比单看 Top 1 占比稳定得多。

3.4 归一化与变体合并

归一化放最后做,因为它是破坏性操作,做完就回不去了。我的做法是先备份原始列,所有归一化写进新列,随时能对照。

SURNAME_MAP = { "gim": "kim", "kym": "kim", "yi": "lee", "rhee": "lee", "ri": "lee", "pak": "park", "bak": "park", "choe": "choi", "ch'oe": "choi", "jeong": "jung", "chung": "jung", "gang": "kang", } def norm_surname(s: str) -> str: if not s: return s key = s.lower().replace("'", "").replace(" ", "") return SURNAME_MAP.get(key, key)

有一个细节值得单独说:归一化之后要重新跑一遍频率统计,对比归一化前后的 Top 10 排序。如果排序发生剧烈变化,说明变体合并的影响很大,那么下游所有基于原始拼法的统计结论都要推翻重来。我吃过这个亏,报告写完才发现姓氏排序变了,只能整段重写。

4. 隐藏故事的四个来源:数据为什么会“失真”

4.1 生成模型的“典型性偏好”

合成人物数据的人名,本质是语言模型在给定指令下的采样结果。模型学到的不是人口登记簿,而是语料里出现过的姓名。语料有个特点:真实姓名越常见,出现次数越多,模型对它的概率估计越高;但语料还有个偏差,就是文本里被提及的人往往是“典型代表”,不是随机抽样。两个偏差叠加,模型输出的姓名分布会比真实人口更陡。

这个偏差不能简单说成错误。如果数据集的目标是“像真人”,那么适度的头部集中是必要的,完全均匀的名字列表反而不像真的。问题在于集中度的把握:头部集中度超过真实基线太多,下游做人口统计类分析时会系统性高估某些群体;集中度太低,又会让数据看起来像随机字符串拼接。

我的处理办法是保留头部、修复尾部——不强行压平分布,而是把明显不合法的尾部组合(不存在的姓、错位的用字搭配)剔掉,再用合法组合补充。这样头部形状基本不动,尾部的合法性提升明显。

4.2 世代错配与人口结构扁平化

世代错配是我在抽检时发现最多的问题类型。具体表现是:名字的世代特征和年龄字段对不上,年轻 persona 配了老年名,或者反过来。前面说过,单条记录看不出来,必须做交叉统计。

做法是按年龄段分箱,统计每个箱内名字用字的分布,看不同箱之间的分布差异。真实人口里,不同年龄段的用字分布差异应该很明显;如果各箱分布几乎一样,说明生成时没有把年龄作为条件。

bins = [0, 30, 45, 60, 120] df["age_bin"] = pd.cut(df["age"], bins=bins) pivot = pd.crosstab(df["age_bin"], df["given"].str[0]) print(pivot.div(pivot.sum(axis=1), axis=0).round(3))

看这张交叉表的行内占比。如果 0-30 岁那一行和 60 岁以上那一行几乎重合,世代信号就是丢的。我在样本上实测过,头部几个高频用字在老年龄段的占比是年轻段的两倍以上,合成数据里这个倍数通常只有一点二到一点五,差距被压缩了。这个压缩不会让数据不可用,但如果你拿它做年龄相关的行为分析,结论会偏。

4.3 结构合法性错误:不存在的姓、错位的字

结构错误比统计偏差更致命,因为它会直接让记录不可信。常见的有三类:拼出了不存在的姓氏组合、姓氏和名字的音节切分错位、复姓被当成了单姓加名字。

第一类最容易查,维护一份合法姓氏白名单,不在白名单里的姓氏全部标红。第二类要靠音节数校验,全名切完之后姓氏长度加名字长度必须等于总长度,不等的就是切错了。第三类需要单独的复姓列表,长度有限,硬编码就行。

VALID_SURNAMES = set([...]) # 常见姓氏白名单 compound = ("남궁", "황보", "제갈", "선우", "독고") def check_structure(row): issues = [] if row["surname"] not in VALID_SURNAMES: issues.append("unknown_surname") if len(row["surname"]) + len(row["given"]) != len(str(row["name"]).strip()): issues.append("split_mismatch") if len(row["given"]) not in (1, 2): issues.append("given_len") return issues

这套规则跑一遍,能拦下绝大部分硬错误。我实测的拦截率在千分之三到千分之八之间,量不大但必须清掉,因为这类记录一旦进入下游,产生的错误输出会非常显眼。

4.4 下游任务里的连锁反应

人名错了,影响不止在名字本身。第一层是称谓,模板拼接时会生成不自然的称呼;第二层是性别代词,backstory 里的指代会跟名字的性别倾向冲突;第三层是评测指标,如果你的评测集用姓名做分组统计,姓氏分布错了,分组对比的结论就不可信。

我遇到过一个典型情况:某个批次的女性 persona 里,高频名字占比异常高,导致按性别分组的文本长度统计出现系统性偏移——因为模板给高频名字配的 backstory 更长。这个偏移在单条记录里完全看不出来,是聚合之后才发现的。所以人名校验不是收尾工作,应该放在数据入库的第一步做。

5. 常见问题与排查技巧实录

5.1 问题速查表

下面这张表是我实际排查过的问题清单,按出现频率排序,遇到异常先对着表扫一遍,能省不少时间。

现象最可能原因排查动作修复方式
姓氏 Top 1 占比偏低拼法未归一化打印姓氏原始值集合应用映射表合并变体
Top 10 全名占比过高生成采样温度偏低计算 Zipf 斜率提高采样多样性后重生成
姓氏含不合法字符切分逻辑覆盖不全查长度不等于 3 的记录补复姓规则
各年龄段用字分布雷同年龄未作为生成条件做年龄段交叉表按年龄分箱重采样
性别与名字冲突率高阈值设置过松统计各用字性别倾向提高判定阈值到 0.85
名字长度分布出现异常峰值混入单字名或复姓看长度直方图单独分流处理
同一人出现多条记录拼法差异导致去重失效归一化后重新去重建归一化唯一键

表格里的每一条我都至少踩过一次,其中“姓氏 Top 1 占比偏低”和“同一人出现多条记录”其实是同一个根因的两种表现,一起排查效率最高。

5.2 三条踩坑经验

第一条,不要在原始字段上做破坏性清洗。我第一版脚本图省事,直接把归一化结果写回了原列,后面想追溯某条记录为什么被合并,已经没有原始值可查了。现在我的习惯是原列只读,所有衍生结果写新列,命名加后缀区分,任何一步都能回滚。

第二条,性别判定用分值不用布尔。一开始我用硬规则,某个字属于女性用字就判女性,结果误报率高得离谱,因为很多字是两性混用的。改成给每个用字算一个倾向分值,只在分值极端时才做判定,其余标记为“不确定”跳过。这样误报率降下来,人工复核量也降下来。下面是我用的判定函数:

def gender_score(given: str, table: dict) -> float: """返回 -1 到 1,负值偏男,正值偏女,接近 0 视为中性""" if not given: return 0.0 scores = [table.get(ch, 0.0) for ch in given] return sum(scores) / len(scores) def judge(given: str, table: dict, thresh=0.85): s = gender_score(given, table) if s >= thresh: return "F" if s <= -thresh: return "M" return "U" # 不判定

第三条,Top N 名单一定要人眼看一遍。这条听起来很土,但特别有效。统计指标只能告诉你分布形状,看不出来“这个名字读起来怪”。我每次跑完统计都会把 Top 50 打出来看一眼,好几次就是靠肉眼发现某个高频名字其实是切分错误产生的拼接串。花五分钟,能省掉后面几小时的排查。

6. 这类人名数据的用法与边界

6.1 适合拿来做的事

人名数据的正当用途其实挺明确。第一是模板与格式测试,验证你的多语言系统在处理不同长度的姓名、复姓、罗马化拼写时会不会崩,这类测试只关心结构合法性,不关心人口学真实性,合成数据完全够用。第二是脱敏替换,把真实姓名替换成同分布结构的假名,保留下游处理逻辑需要的形态特征。第三是压力测试和边界用例生成,专门构造极长名字、全大写拼写、混合字符集这些极端输入。这三类场景里,20 万量级的名字池是够用的,甚至有点富余。

我自己的用法更窄一些,主要拿它做多语言称呼系统的回归测试。因为称呼规则对姓名的结构非常敏感,姓在前还是名在前、有没有敬称后缀、复姓怎么处理,任何一个环节错了都会立刻暴露。这种测试用真实姓名反而不合适,合成名字没有隐私负担,改起来也方便。

6.2 不该拿来做的事与红线

有几件事我明确不建议。第一,不要拿合成人名分布当人口统计结论。前面反复说过,合成数据的姓名分布比真实人口更陡,头部集中度被放大了,用它去推“某姓氏占多少人口”一定会偏。第二,不要把高频名字当成文化典型去引用,那只是采样偏差的产物,不是文化特征。第三,不要用合成姓名去做身份指认或者关联真实个体,这在技术上不可靠,在伦理上也站不住。

还有一条容易被忽略:如果数据集里混入了真实人物的姓名,那怕只有千分之一,也必须清掉。我的做法是维护一份敏感词过滤,姓名归一化之后过一遍,命中就整条剔除。这一步不能省,因为合成数据的价值就在于它和真实个体脱钩,一旦有真实姓名混进去,这个前提就破了。

最后分享一个我一直在用的自查习惯:数据入库前,把姓名、性别、年龄三列拼成一张透视表,随机抽两百行打印出来,从第一行读到最后一行。读起来别扭的地方,八成有问题;读起来顺畅,基本上分布就没大毛病。这个方法没什么技术含量,但比任何单一指标都可靠——因为最后消费这些数据的,终究是读得懂别扭的人。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询