拿到一段 K-pop 歌词文本时,如果只是读一遍,谈不上有什么技术含量。真正需要动手的场景往往是这样的:你想知道某首歌里出现频率最高的词是什么,想统计每位成员的台词行数,或者想把像“D to the E, to the L-I-C-I-O-U-S”这样逐字母拼出来的特殊歌词,从普通文本里自动识别出来。逐行复制到网页词频工具里,遇到几十首歌就会崩溃;直接按空格 split,又会被逗号、连字符和大小写扰乱结果。这次以 TREASURE(崔玹硕、金道荣、朴炡禹)相关歌词片段中这句“D to the E, to the L-I-C-I-O-U-S”为入口,搭建一个最小可运行的 Python 文本分析管线,把清洗、分词、模式提取、词频统计、可视化和结构化输出一次走通。文章中的代码和数据可以直接复制运行,之后替换成自己的歌词文本就能复用。
做这类分析时,最大的误区是一上来就写正则抓词。歌词文本普遍带着说话人标记、标点、大小写、连字符和韩英混排,直接处理很容易出现“看起来对了,换一行就崩”的情况。因此下面先定义清楚输入和输出,再逐步搭建处理流程。
1. 先确定任务和输入模型:一句歌词文本能挖出什么
1.1 输入样例和最小数据模型
先看素材里给出的这段文本特点:D to the E, to the L-I-C-I-O-U-S。它表面是一个带有逗号的英文短句,内部却包含两种特殊结构:
- 字母之间用“to the”连接:
D to the E - 字母之间用连字符连接:
L-I-C-I-O-U-S
把两部分还原,得到的就是DELICIOUS。这种“逐字母拼写单词”的手法在歌词、应援口号、综艺口号里很常见,但它并不符合普通英文词法,通用分词器不会把它当成一个词。
为了让程序可以处理,先定义最小输入模型。每一行歌词由两部分组成:
[说话人标记] 歌词内容例如:
[崔玹硕] D to the E, to the L-I-C-I-O-U-S在代码里,每一行可以表示成这样的结构:
{"speaker": "崔玹硕", "lyric": "D to the E, to the L-I-C-I-O-U-S"}实际工程中,可以用一个列表保存所有行,再逐行处理。不要在一开始就把整首歌词拼成一个超长字符串,否则后面统计说话人分布时会非常痛苦。
1.2 期望输出和它在实际项目里的用途
按照上面这个输入模型,程序完成后应该输出四类结果:
| 输出项 | 含义 | 典型用途 |
|---|---|---|
| 单词频率 | 清洗后每个词出现的次数 | 了解歌词关键词,判断主题 |
| 拼写短语 | 识别出的逐字母拼写结构及还原单词 | 分析特殊歌词写法、口号设计 |
| 说话人统计 | 每位成员出现的行数 | 歌词分工统计、应援物料整理 |
| 结构化文件 | JSON、CSV、图片 | 后续导入表格或做二次分析 |
这些结果并不只是“统计着玩”。在字幕制作、歌词站点内容维护、粉丝物料整理、语料库建设这类场景里,结构化输出可以直接进入下一步流程,避免每次都要重新处理原始文本。
1.3 为什么不用现成的在线词频统计工具
在线工具适合一次性的、几十字的文本。遇到带说话人标记的歌词文件,会遇到三个问题:
- 无法自动剥离
[成员名]标记,统计结果会混入标记字符。 - 无法识别
L-I-C-I-O-U-S这种跨分隔符的拼写结构,在线工具通常只按空格或标点切词。 - 数据要上传到第三方站点,歌词文本可能涉及版权,也不适合反复提交。
自己写脚本的收益不只是“免费”,而是可重复、可配置、可排查。处理结果一旦不对,你可以直接看中间变量,找到是清洗正则的问题、停用词表的问题,还是数据本身编码的问题。下面就从环境准备开始。
2. 环境准备和样例数据:用最小工程结构跑通管线
2.1 Python 版本与依赖安装
这个项目只需要 Python 3.8 及以上版本,以及一个第三方库matplotlib用于可视化。文本处理部分使用标准库re、collections、json、csv,不需要额外安装。
推荐用虚拟环境隔离依赖:
python -m venv venv source venv/bin/activateWindows 下激活命令是:
venv\Scripts\activate然后安装matplotlib:
pip install matplotlib python --version如果matplotlib下载较慢,可以指定国内镜像源:
pip install matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple安装完成后,用python --version确认版本,再用下面命令确认matplotlib可用:
python -c "import matplotlib; print(matplotlib.__version__)"2.2 项目目录和样例数据文件
建议按下面结构组织文件,后续替换歌词文本时只需要改data/lyrics.txt:
lyrics_analysis/ ├── data/ │ └── lyrics.txt ├── output/ ├── analyze_lyrics.py └── requirements.txtrequirements.txt内容:
matplotlib>=3.5data/lyrics.txt内容如下。第一行是素材中给出的歌词片段,其余几行是为了演示词频和说话人统计补写的普通文本,并非原词,实际分析时请替换成有授权的完整歌词:
[崔玹硕] D to the E, to the L-I-C-I-O-U-S [金道荣] You know the lyrics? So delicious [朴炡禹] D to the E, to the L-I-C-I-O-U-S [金道荣] Spell it loud and clear, delicious [崔玹硕] A demo line for frequency test2.3 样例数据的编码说明
歌词文本包含中文、韩文和英文,文件必须统一使用 UTF-8 编码。在 Windows 上用记事本新建文件时,默认可能是 GBK,写入代码后容易出现UnicodeDecodeError。
推荐在代码里读取文件时显式指定编码:
with open("data/lyrics.txt", encoding="utf-8-sig") as f: lines = [line.strip() for line in f if line.strip()]这里用utf-8-sig而不是utf-8,是为了兼容带 BOM 的 UTF-8 文件。BOM 是文件开头几个不可见字节,如果不处理,第一行的[崔玹硕]前面会多出一个\ufeff,导致说话人标记解析失败。
3. 清洗和分词:文字处理的第一个关口
3.1 清洗策略:两路文本,两种用途
很多人处理文本时只用一份清洗结果,这是最容易出问题的设计。以素材中的这行为例:
D to the E, to the L-I-C-I-O-U-S如果要统计普通单词频率,应该把逗号、连字符去掉,让每个字母独立成词;但如果在清洗时把连字符全部替换成空格,L-I-C-I-O-U-S这个拼写结构就被破坏了,后面识别拼写模式时什么都匹配不到。
因此在设计清洗函数时,要把文本分成两路:
- 一路是“用于模式识别”的原始歌词内容,只剥离说话人标记,保留连字符和逗号。
- 一路是“用于词频统计”的干净文本,统一小写、去掉标点、把连字符变成空格。
先把说话人标记剥离出来,这一步对两路通用:
import re SPEAKER_TAG = re.compile(r"^\[([^\]]+)\]\s*(.*)$") def parse_line(line: str): match = SPEAKER_TAG.match(line.strip()) if match: return match.group(1), match.group(2) return None, line.strip()^\[([^\]]+)\]匹配行首方括号,(.*)$捕获后面的歌词内容。这里必须用line.strip()先去掉行首空格,否则正则要求行首是[,遇到缩进行就会失败。
3.2 词频分词的最小实现
词频统计用的清洗函数要处理大小写和标点:
def clean_for_words(lyric: str) -> str: text = lyric.lower() # 标点先替换为空格,避免粘连单词 text = re.sub(r"[,;:!?\"'()]", " ", text) # 连字符替换为空格,让拼写字母独立成词 text = re.sub(r"[-]", " ", text) return text def tokenize(text: str) -> list[str]: return text.split()清洗后的分词效果如下:
| 原始行 | 清洗后文本 | 分词结果 |
|---|---|---|
D to the E, to the L-I-C-I-O-U-S | d to the e to the l i c i o u s | d,to,the,e,to,the,l,i,c,i,o,u,s |
You know the lyrics? So delicious | you know the lyrics so delicious | you,know,the,lyrics,so,delicious |
注意到第一行分词后全是单字母,这些单字母如果直接进入词频统计,会把结果淹没。因此词频统计时要加一个最小词长过滤,把len(word) < 2的丢弃。
3.3 清洗前后对照与检查点
清洗逻辑写完以后,先不要急着写后面的统计代码。建议先跑一个最小检查,把每条原始行和清洗后结果打印出来:
for line in lines: speaker, lyric = parse_line(line) print(repr(line)) print("speaker:", speaker) print("cleaned:", clean_for_words(lyric)) print("---")检查点有三个:
- 说话人标记是否全部剥离。
- 标点是否被替换成空格,有没有出现
deliciousso这种粘连。 - 连字符是否按预期变成空格,模式识别用的原始行是否仍然保留连字符。
这一步如果出错,后面词频和模式提取的结果都会异常,而错误源头往往是最早的清洗函数。
4. 最关键的环节:识别逐字母拼写模式
4.1 逐字母拼写有哪几种常见写法
“逐字母拼写”不是一个严格的语言学概念,它指的是把一个单词拆成多个字母,再用某种连接符串起来。常见写法有三种:
| 写法类型 | 示例 | 特点 |
|---|---|---|
| 连字符连接 | L-I-C-I-O-U-S | 字母之间用-分隔 |
| 点号连接 | L.I.C.I.O.U.S | 字母之间用.分隔 |
| 连接词连接 | D to the E | 字母之间用to the连接 |
素材里的这行歌词混合了后两种:D to the E, to the L-I-C-I-O-U-S。识别时要同时支持连字符序列和to the序列。
4.2 用正则表达式提取拼写片段
可以用一个正则表达式同时匹配两类片段:
SPELLING_SEGMENT = re.compile( r"\b[A-Za-z](?:-[A-Za-z])+\b" r"|\b[A-Za-z](?:\s+to\s+the\s+[A-Za-z])+", re.IGNORECASE )拆开看:
\b[A-Za-z](?:-[A-Za-z])+\b匹配至少两个字母、用连字符分隔的片段,例如L-I-C-I-O-U-S。\b[A-Za-z](?:\s+to\s+the\s+[A-Za-z])+匹配D to the E这种结构,支持多个to the连接。re.IGNORECASE让大小写都能匹配。
提取函数:
def get_spelling_letters(segment: str) -> str: letters = re.findall(r"[A-Za-z]", segment) return "".join(letters).upper() def extract_spelling_segments(lyric: str) -> list[str]: segments = [] for match in SPELLING_SEGMENT.finditer(lyric): segments.append(match.group(0)) return segments对素材中这行文本,结果是:
extract_spelling_segments("D to the E, to the L-I-C-I-O-U-S") # 预期得到 ["D to the E", "L-I-C-I-O-U-S"]注意正则不会跨过逗号去匹配D to the E, to the L-I-C-I-O-U-S整串,因为,不在允许的连接符里。这是合理的:先识别片段,再合并还原。
4.3 拼写片段还原成完整单词
识别出片段后,把每个片段中的字母抽出来按顺序拼接,就能还原单词:
def merge_spelling_in_line(lyric: str) -> list[str]: segments = extract_spelling_segments(lyric) if not segments: return [] merged = "".join(get_spelling_letters(seg) for seg in segments) return [merged] if len(merged) >= 3 else []对素材中的行,两个片段分别抽出D E和L I C I O U S,按出现顺序拼接后得到DELICIOUS。
还原逻辑有两个要注意的地方:
- 不是所有拼写片段都要合并。如果分析目标是逐段输出,可以只把片段本身放进结果;如果目标是还原单词,才需要按顺序拼接。
- 拼接时只取字母,去掉
to the、空格、连字符、点号,避免DTO THE E这种脏数据。
4.4 这个环节最容易踩的坑
第一个坑是连字符不一致。歌词文本如果来自网页复制,连字符可能是全角-或长划线–,不是 ASCII 的-。正则只匹配-,遇到全角字符就匹配不到。处理方式是在清洗前做字符归一化:
text = text.replace("-", "-").replace("–", "-").replace("—", "-")第二个坑是finditer的顺序。如果使用多个正则分别匹配再手动排序,容易因为str.index()误判位置。推荐的做法是像上面一样把多个分支写进同一个正则,利用finditer天然按位置返回结果。
第三个坑是空格连接的单字母序列。\b[A-Za-z](?:\s+[A-Za-z])+\b看起来能匹配D to the E的变体,但也会把普通句子里的a b c这种零散单字母当成拼写结构。实际项目中要结合文本特点收紧规则,宁可少匹配,也不要误报。
5. 词频、说话人分布与可视化
5.1 说话人标记解析
在前面parse_line的基础上,逐行累加说话人次数:
from collections import Counter speaker_counter = Counter() for line in lines: speaker, lyric = parse_line(line) if speaker: speaker_counter[speaker] += 1这里使用Counter而不是普通字典,是因为Counter自带most_common方法,后续输出排序更方便。
5.2 词频统计与停用词处理
停用词表不需要追求通用,而是要根据当前文本调整。歌词里的the、to、a对主题分析没有帮助,可以直接过滤:
STOPWORDS = {"the", "to", "a", "an", "of", "and", "or", "in", "on", "for", "with", "is", "are", "it", "so", "you"} word_counter = Counter() for line in lines: _, lyric = parse_line(line) for word in tokenize(clean_for_words(lyric)): if len(word) >= 2 and word not in STOPWORDS: word_counter[word] += 1注意len(word) >= 2这个过滤条件。如果没有它,D to the E清洗后产生的单字母会把整个词频榜单占满,演示数据里尤其明显。
5.3 用 matplotlib 输出可视化
词频结果适合用柱状图展示:
import matplotlib.pyplot as plt def draw_word_freq(counter: Counter, top_n=10, output="output/word_freq.png"): items = counter.most_common(top_n) labels = [word for word, _ in items] counts = [count for _, count in items] plt.figure(figsize=(8, 5)) plt.bar(labels, counts) plt.title("Top Words in Demo Lyrics") plt.tight_layout() plt.savefig(output, dpi=150) plt.close()保存成图片而不是直接plt.show(),是为了让脚本在无图形界面的环境下也能运行,适合服务器部署和批处理。
5.4 中韩文字体问题的处理
如果图表标题或歌词文本包含中文、韩文,默认字体很可能显示成方块。处理方式是根据操作系统设置字体:
import platform system = platform.system() if system == "Windows": plt.rcParams["font.family"] = "Malgun Gothic" elif system == "Darwin": plt.rcParams["font.family"] = "AppleGothic" else: plt.rcParams["font.family"] = "Noto Sans CJK KR" plt.rcParams["axes.unicode_minus"] = Falseaxes.unicode_minus设置为False,是为了避免坐标轴负号显示成方块。实际项目里,如果担心字体不稳定,可以在图表标题和标签中统一使用英文,正文说明再使用中文。
6. 运行验证与结果检查
6.1 main 函数的组织方式
把前面所有函数汇总到analyze_lyrics.py中,main函数负责读取数据、统计、输出和绘图:
import json import csv def main(): with open("data/lyrics.txt", encoding="utf-8-sig") as f: lines = [line.strip() for line in f if line.strip()] speaker_counter = Counter() word_counter = Counter()