📰 来源:Towards Data Science | 📅 翻译日期:2026年6月30日
🔗 原文:查看原文
🤖 翻译:DeepSeek AI · 仅供参考
经典NLP能走多远?从词袋到Stacking在Spooky作者识别上的探索
作者归属是测试NLP模型的好方法,因为它不仅关注句子说什么,还关注如何说。Kaggle的Spooky Author Identification竞赛是这个挑战的简化版:给定哥特或恐怖小说中的一个句子,模型需要预测它是Edgar Allan Poe (EAP)、Mary Wollstonecraft Shelley (MWS) 还是 H. P. Lovecraft (HPL) 所写。
乍一看,这是一个典型的三类文本分类问题。但实际上,它更为复杂。这些作者都写类似的主题:恐惧、神秘、死亡、氛围和超自然。简单的关键词不足以区分它们。相反,重要的线索往往是风格上的:功能词、标点、字符模式、短短语、句子节奏以及每个作者构建句子的方式。
这使得该项目成为一个探索特定问题的好方法:
当我们精心选择表示并诚实地评估时,经典NLP能走多远?
我通过构建一系列能力逐渐增强的经典模型来完成任务:
- 一个快速的Vowpal Wabbit单词基线
- 一个更丰富的包含标点和字符n-gram的VW模型
- 一个调优的TF-IDF集成
- 一个使用折外预测的堆叠稀疏文本集成
- 一个小型表示调查,比较稀疏特征、BM25、Word2Vec和FastText
目标不仅是提高分数,还要理解哪些表示有帮助,哪些指标提升了,以及每个结果来自哪个评估设置。
本文重点介绍项目的方法、结果和解读。我将介绍主要的实现选择并分享关键代码片段,但不会包含笔记本中的每一行。完整的可执行笔记本,包括完整实现和输出,可在文末链接的GitHub仓库中找到。
数据集与评估设置
数据集包含 19,579 条标注训练句子和 8,392 条未标注测试句子。类别分布轻微不平衡:

我将标签编码为从1开始的整数,因为Vowpal Wabbit的One-Against-All多类模式期望标签从1开始。
train_texts = pd.read_csv(DATA_DIR / "train.csv", index_col="id")
test_texts = pd.read_csv(DATA_DIR / "test.csv", index_col="id")
AUTHOR_CODE = {"EAP": 1, "MWS": 2, "HPL": 3}
train_texts["author_code"] = train_texts["author"].map(AUTHOR_CODE)
print(f"Train: {len(train_texts)} sentences Test: {len(test_texts)} sentences")
print(train_texts["author"].value_counts(normalize=True).round(3))为了在本地比较模型,我使用固定的随机种子进行了单次分层的70/30训练验证分割。这保持了类别比例的稳定,并确保每个模型都在相同的保留样本上进行评估。
train_texts_part, valid_texts = train_test_split(
train_texts,
test_size=0.3,
random_state=17,
stratify=train_texts["author_code"]
)
y_part = train_texts_part["author_code"].values
y_valid = valid_texts["author_code"].values我主要关注三个指标:
- Accuracy:容易理解,但只衡量最终的顶级类别决策。
- Macro-F1:用于检查性能在三位作者之间是否平衡。
- Multiclass log loss:Kaggle官方指标,也是本项目最重要的指标,因为它评估预测概率的质量,而不仅仅是预测类别。
对数损失奖励自信的正确预测,并严重惩罚自信的错误预测。这在提交是EAP、HPL和MWS概率分布的竞赛中很重要。
1. 仅单词的Vowpal Wabbit基线
我从Vowpal Wabbit开始,因为它速度快、处理稀疏数据好,并且非常适合线性文本模型。VW训练在线线性模型,将特征哈希到固定特征空间,并通过One-Against-All处理多类分类。
对于第一个基线,我只使用了长度至少为三个的小写单词特征。
def to_vw_words(df, is_train=True):
"""VW line: '<label> |text <words of 3+ chars>'."""
lines = []
for i in range(len(df)):
label = df["author_code"].iloc[i] if is_train else 1
text = df["text"].iloc[i].lower().replace("|", "").replace(":", "")
words = " ".join(re.findall(r"\w{3,}", text))
lines.append(f"{label} |text {words}\n")
return lines一个重要的实现细节是VW如何处理多次遍历。当VW直接读取文件时,诸如 passes 和 cache 选项按预期工作。当通过Python API手动提供示例时,我必须自己循环文件。
N_PASSES = 10
vw = Workspace(
oaa=3,
loss_function="logistic",
ngram=2,
b=28,
quiet=True,
final_regressor=f"{OUTPUT_DIR}/spooky_words.vw"
)
for _ in range(N_PASSES):
with open(f"{OUTPUT_DIR}/train_words.vw") as f:
for line in f:
vw.learn(line)
vw.finish()在70/30保留分割上,仅单词的VW基线达到了:

对于一个使用简单单词和二元特征的快速线性模型来说,这已经是一个很强的结果。它也建立了一个有用的基线:任何添加的表示或集成层都需要超过这个标准。
2. 丰富的VW:添加风格感知特征
作者归属不仅仅是主题分类。模型还需要访问反映写作风格的线索。对于更丰富的VW模型,我将输入分成三个命名空间:
|w:用于单词,包括短功能词|p:用于标点|c:用于字符n-gram
def char_ngrams(text, ns=(2, 3, 4)):
"""边界感知的字符n-gram;空白/边缘变为'_'。"""
t = "_" + re.sub(r"\s+", "_", text) + "_"
ngrams = []
for n in ns:
ngrams.extend(["char" + t[i:i+n] for i in range(len(t)-n+1)])
return ngrams这个增强模型显著提高了性能,特别是多类对数损失。
3. TF-IDF与线性SVM集成
接下来,我尝试了TF-IDF特征与线性SVM的组合。通过调整n-gram范围和正则化参数,模型性能进一步提升。
4. 堆叠稀疏文本模型
堆叠(Stacking)是集成学习的一种形式,通过使用基础模型的预测作为新模型的输入来学习如何组合它们。我使用了不同基础模型的折外预测来训练一个简单的逻辑回归元模型。
5. 表示调查
最后,我比较了多种表示:稀疏特征的TF-IDF、BM25、Word2Vec和FastText。每个表示在相同评估设置下进行测试,以揭示其相对优势。
总结
经典NLP方法,当仔细选择特征并合理解释时,在作者归属任务上可以达到很强的性能。从简单的单词基线到丰富的堆叠集成,每个步骤都展示了特征工程和模型组合的价值。
关键发现:功能词、标点模式和字符n-gram对于风格归因至关重要,而概率的校准(对数损失)比纯粹的分类准确率更能反映模型质量。
参考资料
- Kaggle Spooky Author Identification竞赛
- GitHub仓库:完整代码和笔记本
- Vowpal Wabbit文档
- Scikit-learn TF-IDF与SVM
评论已关闭