从零开始做歌词生成器 - 1 - 歌词清洗与分析

上一篇中详述了歌词的抓取部分,大约抓取到了 3 万 7 千多篇歌词,未经过任何清洗。在这一篇中,需要对歌词做一些简单的清洗和分析工作。

从零开始做歌词生成器 - 1 - 歌词清洗与分析

转载请注明出处:https://gaussic.com/lyric-generation-2/

接上一篇:从零开始做歌词生成器 - 0 - 抓取网易云 3 万首歌词

上一篇中详述了歌词的抓取部分,大约抓取到了 3 万 7 千多篇歌词,未经过任何清洗。在这一篇中,需要对歌词做一些简单的清洗和分析工作。

代码暂时放在这个 repo 里,到后面整合到完整的系统中:gaussic/crawl_scripts

环境依赖:

jieba、gensim、pandas

前言

如下图所见,这些歌词中包含了中、韩、日、英四种语言,中文还分繁、简。以及时间线、工作人员等等。由于是针对中文的歌词生成器,因此需要过滤掉大量的文本。

lyric-1-1.jpg

关于时间线、工作人员,基本都有固定的模式,可以使用正则表达式去除。

关于语言的问题,有一个简单的方案,把外文歌手从库中删除,这样做没有办法排除有些华语歌手唱的外文歌曲,以及一些带有中文翻译的歌曲,治标不治本。另一个解决方案,是根据中文字符区间,用正则表达式来处理,这样似乎更加合情合理。

此外,为了缩小词汇表的大小,减少模型参数,将繁体字转换为简体字,这个可以通过繁简对照表完成。

另外一个问题,同一个歌手的一首歌会有多个不同的版本(Remix,Live 等等),但是歌词是相似的,需要尽量的只保留一个版本,因此需要去重。当然,这个步骤是可选的,保存一定的重复对模型其实影响并不大。对于去重,需要计算各文档相似度,然后再去除相似度高的文档。

关于以上步骤的实现,我们逐步叙述。

初步清洗

大部分的时间轴和额外信息,被包在 [] 中,可以把这一部分直接去除。此外,还有一工作人员的信息,基本(并非全部)都有固定的格式,可以根据几个关键词去除大部分。

关于语言判断,中文字符区间是 \u4e00-\u9fa5,统计符合该区间内字符数量,如果超过 8 成都是中文,则判断为中文。这个百分比可以调整,因为还存在一些双语歌词。

使用正则表达式,初步清洗的函数如下:

def open_file(filename, mode='r'):
    return open(filename, mode=mode, encoding='utf-8', errors='ignore')


def is_chinese(text):
    text = ''.join([x.strip() for x in text.split('\n')])
    res = ' '.join([r for r in re.findall(r"[\u4e00-\u9fa5]+", text)])  # 中文字符区间
    return len(res) >= 0.8 * len(text)  # 8成以上是中文


def clean_text(filename):
    text = open_file(filename).read()
    text = re.sub(r"\[.*\]", "", text)  # 过滤时间轴
    text = re.sub(r"作词.*\n", "", text)  # 过滤掉工作人员
    text = re.sub(r"作曲.*\n", "", text)
    text = re.sub(r"编曲.*\n", "", text)
    text = re.sub(r"演唱.*\n", "", text)
    text = re.sub(r"制作人.*\n", "", text).strip()
    return text

繁简转换

部分的粤语歌繁体居多,因此可以考虑将繁体转换为简体,降低词汇表大小。

总结了一张对照表,格式如下:

瀋	沈
畫	划
鍾	钟
靦	腼
餘	余
鯰	鲇
鹼	碱
㠏	㟆
...

我们需要读取这张表,再将初筛后的文本转换为简体,需要两个辅助函数:

def read_convert_words(filename):
    """读取繁简字体转换表"""
    tr_to_cn = {}
    with open_file(filename) as f:
        for line in f:
            key, value = line.strip().split()
            tr_to_cn[key] = value
    return tr_to_cn


def convert_tr_to_cn(sentence, tr_to_cn):
    """繁简转换"""
    cn_s = ''
    for x in sentence:
        if x in tr_to_cn:
            x = tr_to_cn[x]
        cn_s += x
    return cn_s

接下来,就是遍历所有目录下的所有歌词文档,然后一片片处理再转存:

base_dir = "data"
new_dir = "data_clean"   # 保存到新的目录下
if os.path.exists(new_dir):
    shutil.rmtree(new_dir)
os.mkdir(new_dir)

cnt = 0   # 编号
tr_to_cn = read_convert_words('tr-cn.txt')
for cur_dir in os.walk(base_dir):  # 遍历所有文档
    for filename in cur_dir[2]:
        try:
            file_dir = os.path.join(cur_dir[0], filename)
            data = clean_text(file_dir)

            if is_chinese(data) and len(data) >= 200:  # 中文,200字符以上
                data = convert_tr_to_cn(data, tr_to_cn)   # 转换为简体

                filename = convert_tr_to_cn(filename, tr_to_cn)

                filename = ''.join(filename.split('.')[:-1])
                new_file = filename + ' - ' + str(cnt) + '.txt' # 防止重名覆盖,打个编号
                open_file(os.path.join(new_dir, new_file), 'w').write(data)  # 汇总写入新目录
                cnt += 1
        except:
            pass

这里只保留清洗之后 200 字符以上的歌词,处理完毕大约剩下 16000 多篇。

经过初筛后和繁简转换后的示例如下:

![lyric-1-2.jpg])(https://gaussic.com/content/images/2020/01/lyric-1-2.jpg)

相比原始的数据,已经干净了许多。

歌词去重

接下来还需要处理歌词重复的问题,查看剩下的文档,发现重复情况还是比较严重的,仅陈奕迅的一首 K歌之王 就出现了 10 次以上。

去重的一个简单思路是提取所有文档的 TF-IDF 特征向量。然后再逐个计算每一篇文档的特征向量与其他所有文档的相似度。如果相似度最高的两篇文档的相似度小于所设阈值,那么说明这篇文档没有出现过。

有一个问题是,这个算法的复杂度是 $O(n^2)$,1.6 万文档计算量过亿,外加每篇文档的相似度对比还需要一定的时间,总体可能需要话费数小时。

另外一种快速的海量文档匹配方法,Simhash,测试之后,发现速度虽然快,但是效果并不让人满意。

再次分析数据,把文档按名称排序后,终于找到了优化方法:

lyric-1-3.jpg

依次打开名称相似的文档,发现其中的内容是几乎相同的。也就是说,我们每次只要对比名称相近的几篇文档就可以了,这样 $O(n^2)$ 变成了 $O(n*k)$,优化相当显著。

How do I compare document similarity using Python? 一文中给出了一个使用 gensim 实现文档相似度计算的实例。由于要同时处理多篇文档,在此对其进行了进一步的封装:

# coding: utf-8

import os
import sys
import gensim
import shutil


def open_file(filename, mode='r'):
    return open(filename, mode=mode, encoding='utf-8', errors='ignore')


class DocSimilarity(object):

    def __init__(self, in_dir):
        """读取所有歌词"""
        self.lyrics = []  # 所有歌词
        self.fnames = []  # 所有文件名
        for fname in sorted(os.listdir(in_dir)):  # 排序,让内容相似的更加靠近
            self.fnames.append(fname)
            self.lyrics.append(list(open_file(os.path.join(in_dir, fname)).read()))

        print("原歌词总数:", len(self.lyrics))
        self.corpus_pr()

    def corpus_pr(self):
        """gensim文档tf_idf计算"""
        dictionary = gensim.corpora.Dictionary(self.lyrics)  # 文档词汇表
        corpus = [dictionary.doc2bow(lyric) for lyric in self.lyrics]  # 文档BOW特征向量
        tf_idf = gensim.models.TfidfModel(corpus)
        corpus = list(tf_idf[corpus])  # 文档TF-IDF特征

        self.vocab_size = len(dictionary)
        self.corpus = corpus
        print("文档TF-IDF特征计算完毕。")

    def remove_sim(self, out_dir, max_similarity=0.2, last_k=20):
        """移除相似文档,保存到新目录"""
        if os.path.exists(out_dir):
            shutil.rmtree(out_dir)
        os.mkdir(out_dir)

        cnt, yes = 1, 1
        c_corpus = [self.corpus[0]]  # 第0篇直接放入
        open_file(os.path.join(out_dir, self.fnames[0]), 'w').write(''.join(self.lyrics[0]))

        for i in range(1, len(self.corpus)):
            try:
                # 注意,只对比last_k篇文档,而不是所有歌词
                sims = gensim.similarities.Similarity('/Users/gaussic/',
                                                      c_corpus[-last_k:],
                                                      num_features=self.vocab_size)
                if sims[self.corpus[i]].max() < max_similarity:  # 如果最相似文本的相似度小于阈值
                    c_corpus.append(self.corpus[i])
                    open_file(os.path.join(out_dir, self.fnames[i]), 'w').write(''.join(self.lyrics[i]))
                    yes += 1
                cnt += 1
            except:
                pass
            if cnt % 2000 == 0:
                print('已处理:', cnt, '保留:', yes)
        print("保留歌词数:", yes)


if __name__ == '__main__':
    data_dir = sys.argv[1]
    docsim = DocSimilarity(data_dir)
    # 对比前20篇文档,相似度低于0.2
    docsim.remove_sim('data_unique', max_similarity=0.2, last_k=20)

运行上述代码,原先的 1.6 万文档经过去重后剩余约 5800 篇,且用时不到 5 分钟,效果提升显著。

整合所有歌词

在经过以上清洗之后,数据应该算比较干净了。为了方便后面的训练和测试,现在把所有独立的文档分词并整合到一个文档中,做进一步的预处理。

分词使用jieba分词工具,每一行分词后,每个词以空格隔开

需要注意的 3 点是:

  1. 部分歌词前部和后部仍然有一些噪声,考虑直接扔掉前 3 行和后 3 行。
  2. 分词后列表中存在大量空格和空字符,可以结合 join()split() 去除。
  3. 一行歌词太长和太短都会对模型的训练造成一定的影响,因而只保留适当长度的行。
import os
import jieba

jieba.enable_parallel(10)  # 并行分词

base_dir = 'data_unique'


def open_file(filename, mode='r'):
    return open(filename, mode=mode, encoding='utf-8', errors='ignore')


def lyric_group():
    lyric_full = open_file('lyric_full.txt', 'w')
    for fname in sorted(os.listdir(base_dir)):
        data = open_file(os.path.join(base_dir, fname)).readlines()
        if len(data) <= 6:  # 歌词太短,不要
            continue
        lyric = []
        for line in data[3:-3]:  # 前3行后三行都不要
            cur_line = list(jieba.cut(line.strip().lower()))
            if len(cur_line) >= 30:  # 太长不要
                continue
            lyric.extend(' '.join(cur_line).split())
            if len(lyric) >= 5:
                lyric_full.write(' '.join(lyric) + '\n')
                lyric = []
        lyric_full.write('\n')  # 每首歌词用空行隔开

    lyric_full.close()

整合后的歌词片段示例:

剩下 破折号 有些 人 什么 都 不 知道

好像 一个 人 巨大 的 问号

我 也 不 晓得 他们 如何是好

我 只有 祈祷 不用 别的 标点 和 符号

只 需要 一个 感叹号 不爱 什么 天荒 和 地 老

最 喜欢 一个 感叹号 不管 什么 伟大 和 渺小

只要 只要 出乎意料 感叹 我 的 奇妙

有些 人有 一双 怪 眉毛

皱 起来 好像 一对 括号

他们 越 烦恼 看来 越是 可笑

oh ~ ~ 有些 人 不当 主角

在 人家 的 故事 当 逗号

不 晓得 时候 不 早

我 只有 祈祷 不用 别的 标点 和 符号

只 需要 一个 感叹号 不爱 什么 天荒 和 地 老

最 喜欢 一个 感叹号 不管 什么 伟大 和 渺小

只要 只要 出乎意料 感叹 我 的 奇妙

不论 恋爱 还是 开玩笑 这 是 找 一时 热闹

我要 别人 看到 也 会 说不得 了

不用 别的 标点 和 符号

只 需要 一个 感叹号 不爱 什么 天荒 和 地 老

数据分析

这一步的数据分析,同样为构建模型时的参数选择服务。

首先是总词数和词汇量:

from collections import Counter
lyrics = open_file('lyric_full.txt').read().strip().replace('\n', ' ').split()
counter = Counter(lyrics)
count_pairs = counter.most_common()

print("总词数:", len(lyrics))
print("词汇量:", len(counter))
print("高频词:", count_pairs[:10])

输出:

总词数: 1011164
词汇量: 49280
高频词: [('的', 48193), ('我', 44841), ('你', 41828), ('在', 12436), ('是', 11583), ('了', 10560), ('爱', 8357), ('不', 7975), ('都', 7203), ('有', 6621)]

可以看到,词数达到了 100 万以上,词汇量接近 5 万,前 10 高频词无疑就是汉语常用字。进一步观察:

print(count_pairs[5000])
print(count_pairs[10000])
print(count_pairs[20000])
print(count_pairs[40000])

输出:

('抓不住', 20)
('别爱', 8)
('距', 3)
('严重性', 1)

排行 1 万的词出现 8 次,2 万的词出现 3 次,而 4 万以后的词只出现了 1 词。需要知道,我们的总词量是 100 万以上,这些词频太低的词对模型的影响是微不足道的,因此可以考虑将 1 万以后的这些词替换成 &lt;unk&gt; 标志,词汇表的减小大大降低了模型复杂性。

unk = 0
for i in range(10000, len(counter)):
    unk += count_pairs[i][1]
print("UNK所占百分比:{:.3}%".format(unk / len(lyrics) * 100))

取词汇表大小为 1 万,UNK 所占百分比约为 9%,可以进一步地删除部分数据来减小这个量。

count_pairs = counter.most_common(10000)
words, _ = list(zip(*count_pairs))
ws = set(words)    # 前1万个词

lyrics = []
for line in open_file('lyric_full.txt'):
    line = line.strip().split()
    if len(line) == 0:
        continue
    if len([x for x in line if x not in ws]) <= 0.3 * len(line):
        lyrics.append(line)
print(len(lyrics))

在只取 1 万词的情况下,过滤掉 unk 比例超过 0.3 的行,得到 12.5 万行歌词。

import pandas as pd

lengths = list(map(len, lyrics))
lengths = pd.DataFrame(lengths, columns=['lengths'])
print(lengths.describe())
输出:
             lengths
count  125940.000000
mean        7.424782
std         2.089210
min         5.000000
25%         6.000000
50%         7.000000
75%         9.000000
max        29.000000

总行数为 125940,每行平均长度 7.42,最小长度为 5,最大为 29,75% 的行长度为 9。

有一些模型在批处理时,需要定长的数据,因此需要把每行 pad 成固定的长度,不足的补 0,太长的裁剪,在这里,我们可以得出,把长度定为 10 左右会比较合理。

这一些分析,不似产品汪所强调的情绪、情节、情怀,但都是对于参数设置非常有意义的分析,在后面的章节会用到。因为我们的目的不单单是通过简单的词频统计来找到歌手的 pattern,而是要创造出一个能够写出兼具各家风格的歌词生成器。

代码暂时放在这个 repo 里,到后面整合到完整的系统中:gaussic/crawl_scripts