简介:一套基于TextCNN的中文文本情感分析实战资源,面向自然语言处理初学者与算法工程师,解决中文情感分类任务的建模与部署需求。资源完整覆盖从中文语料清洗、停用词过滤、分词、词向量映射到TextCNN模型搭建、训练评估与模型导出的全流程,并附带正负向情感语料与预训练权重,支持离线加载直接推理,适合快速上手或作为课程设计、论文复现的起点。
整个压缩包共24个文件、约89.49MB,主要包含5个Python脚本(数据预处理、模型训练、评估、推理)、4个Jupyter Notebook(分阶段图文分析)、文本词典与标签数据、模型架构图与准确率曲线图、h5及checkpoint模型权重等。文件层次清晰,Notebook与脚本一一对应,便于对照学习。当前已有499人学习下载,资源提供“代码+数据+模型”三件套,无需额外准备数据集即可复现实验,还可基于接口替换自己的语料二次训练。
1. 中文情感分析为什么绕不开 TextCNN:一个 60 行模型吃掉 80% 基线效果
判断一条商品评论是好评还是差评,很多人第一反应是上 BERT。但真实业务里文本短、数据量不大、推理要压到毫秒级,BERT 的性价比往往很难看。TextCNN 用一层卷积加一层池化,就能在中文短文本情感分类上拿到接近复杂模型的基线精度,结构完全透明、训练速度快、CPU 上也能轻松部署。电商舆情、客服工单、评价聚合这类场景,TextCNN 往往是第一个真正落地的模型。这个方案适合手里有一份已标注的中文评论数据、想快速跑通情感二分类或三分类、同时希望推理阶段不依赖 GPU 的团队。下面的流程用 PyTorch 实现一套完整链路:数据清洗、分词、词表构建、模型训练、保存与推理,所有代码可直接运行。
2. TextCNN 的核心机制与中文适配:从 n-gram 到卷积核的选择逻辑
2.1 卷积核怎么理解"气死我了"这种短文本
TextCNN 本质上是把文本当成一维信号做卷积。句子先按词表映射成整数索引,再通过 Embedding 变成形状为 (batch_size, seq_len, embedding_dim) 的稠密向量。这时张量的维度顺序有一个容易绕的点:Conv1d 要求通道维在第二维,也就是 (batch, channels, seq_len)。所以 Embedding 之后要做一次permute把 embedding 维换到通道位,这一行代码是初学者报错最集中的位置。
卷积核的作用等价于一个 n-gram 抽取器。kernel_size=3 的卷积核一次覆盖三个连续 token,把它们的向量加权求和后经过非线性激活,再通过池化取最大值。可以这样理解"气死我了"这句评论:情感信号主要由"气死"这个局部组合承载,卷积核滑到"气死"附近时产生强激活,哪怕句子再长,池化层也能把这个最强信号捞出来。正是"情感词局部可判"这个特性,让 TextCNN 能以丢弃长距离结构信息为代价,换到很高的训练和推理速度。
TextCNN 和循环网络(LSTM/GRU)本质上的区别在于:RNN 按顺序维护记忆状态,每个位置依赖前一个位置的计算结果;CNN 是滑窗并行扫描,所有位置同时计算。短文本情感判断更多依赖局部词组而非长距离依赖,CNN 在保留关键能力的同时获得了并行优势,这也是它在高并发线上服务里站稳脚跟的底气。
2.2 中文分词与字级别输入:什么时候用字,什么时候用词
中文没有天然空格,TextCNN 的输入必须先做分词或拆字。两类做法的效果差异值得细说,先给结论:
| 对比项 | 词级别输入 | 字级别输入 |
|---|---|---|
| 语义粒度 | 细,直接保留词边界 | 粗,依赖卷积组合重建 |
| 词表规模 | 3 万到 10 万,低频词多 | 3 千到 8 千,覆盖率极高 |
| OOV 处理 | 新词、错字容易掉出词表 | 单字全覆盖,鲁棒性好 |
| 训练收敛 | 快,特征直接 | 慢,需要更多卷积层组合 |
| 适用场景 | 规范文本、大语料 | 口语化、错别字多的社交语料 |
词级别用 jieba 切成词序列,词表膨胀到几万甚至十几万,典型问题是低频词的向量训练不充分,词表外词直接丢弃。字级别把句子拆成单字,词表压到几千,任何词都不会完全查不到。代价是丢掉了词边界信息——"喜欢""苹果"这类多字词被拆成单字后,语义要靠卷积核重新组合,模型负担变大。还有一个常被忽略的细节是分词质量:jieba 对"不怎么好吃"这种否定歧义短语会切出"不/怎么/好吃",对情感分析是模型的一个大坑,因为 3-gram 窗口会看到"怎么好吃"这种假正向信号。字级别反而天然避免这个问题。
实际选择规则我是这样掌握的:数据量在 10 万条以下、以社交媒体评论为主的语料,优先字级别;数据量大而且经过规范清洗的文本(电商标题、官网评论、新闻短评),词级别收益更明显。这不是玄学,本质是词表覆盖率与语义粒度之间的取舍——语料越不规范,覆盖率权重越高。
2.3 一个完整的词向量初始化与词表构建流程
无论词还是字,输入都需要一个向量矩阵。下面这段代码把分词后的句子转成词索引,并建立 Embedding 的基础结构:
import jieba from collections import Counter raw_texts = [ "这家酒店的床很舒服,服务态度也好", "房间隔音太差,一晚上没睡好", "前台小姐姐很热情,下次还来", "卫生状况堪忧,洗手间有异味", "性价比非常高,位置也方便", "空调是坏的,热了一整夜", "早餐种类丰富,味道不错", "办理入住等了四十分钟,体验很差" ] labels = [1, 0, 1, 0, 1, 0, 1, 0] # 1=好评, 0=差评 tokenized = [list(jieba.cut(t)) for t in raw_texts] word_count = Counter(w for tokens in tokenized for w in tokens) # 预留索引 0 给 PAD,其余词按频率降序排列 vocab = {w: i + 1 for i, (w, _) in enumerate(word_count.most_common(5000))} vocab['[PAD]'] = 0 vocab['[UNK]'] = len(vocab) def sentence_to_ids(sentence, max_len=32): tokens = list(jieba.cut(sentence)) ids = [vocab.get(w, vocab['[UNK]']) for w in tokens] if len(ids) < max_len: ids = ids + [vocab['[PAD]']] * (max_len - len(ids)) else: ids = ids[:max_len] return ids print(sentence_to_ids("这家酒店的床很舒服,服务态度也好"))这段示例只有 8 条数据,目的是在没有外部文件的情况下先把链路跑通。替换成真实 CSV 时,字段结构建议是text,label两列,读取改用pandas.read_csv。逻辑上有三个地方要盯紧:一是 PAD 固定用 0 号位,后续nn.Embedding(padding_idx=0)才能正确屏蔽填充位;二是 UNK 索引必须在词表构建完成后追加,不能在循环里动态分配,否则同一批数据每次运行的编码都不同;三是截断策略默认取头部 32 个 token,但很多评论的结论落在段落末尾,改成"前 16 + 后 16"的拼接方式能避免丢掉句尾信号。
还有一个初始化选择需要提前交代。Embedding 有两种常见初始化:随机初始化随模型一起训练,以及用预训练词向量初始化。上面的代码展示的是常规非静态模式,适合大多数从零开始的项目。预训练初始化在数据量小于一万条时收益明显,但要求预训练语料和你的领域接近,否则迁移过来的向量语义偏差会拖累收敛。
3. 把数据做成模型能吃的样子:清洗、标注划分与 Dataset 组织
3.1 清洗与分词的完整流程
爬虫拿到的数据不能直接进模型。统一小写、去 URL、去 HTML 实体这些是常规操作,中文场景下标点符号要单独斟酌。感叹号带有情感强度,"太差了!!!"如果全部删掉信号就丢了。一般做法是把连续重复标点压缩成单个,并让感叹号保留在序列里继续参与卷积。压缩标点的动作要在分词之前做,顺序反了,jieba 会把"!!!"单独切成一个无意义 token,卷积结果受到干扰。
清洗规则看起来简单,落地时有一个隐蔽的顺序依赖:先去 URL 和标签,再压缩标点,最后分词。如果先分词再去标点,切分粒度完全不同。比如"太好了!!!"先分词得到"太好了 / !!!",再删除感叹号后只剩"太好了",卷积核的有效窗口就会落在三个字上面;先压缩标点再分词,得到"太好了!",感觉上一个标点保留了情绪边界。这个差异在短文本上会直接影响准确率。
import re def clean_text(raw): # 粗清洗:小写、去 URL、去 HTML 实体 text = raw.lower() text = re.sub(r'https?://\S+', '', text) text = re.sub(r'<[^>]+>', '', text) text = re.sub(r'&[a-z]+;', '', text) # 压缩连续标点,只保留第一个 text = re.sub(r'([!!??。,,]{2,})', r'\1', text) text = re.sub(r'\s+', ' ', text).strip() return text # 对原始语料执行清洗 clean_texts = [clean_text(t) for t in raw_texts] for t in clean_texts: print(t)清洗之后还要决定是否过滤停用词。在 TextCNN 里,"的、了、吗、呢"这类纯语法虚词会稀释卷积核的响应,让池化层选中最强烈的信号时被这些高频词干扰。但如果停用词表误杀了"很、太、不"这类副词,情感强度表达就被破坏了。建议使用一份精简停用词表,只过滤无实义的虚词,保留程度副词和否定词。这里没有放一份通用词表,因为它和语料领域强相关,电商评论的"宝贝""快递"和酒店评论的"前台""隔音"完全是两套词,最好是跑完第一次训练后,把误分类样本里出现频次最高的无义词再补进停用表。
3.2 构建 Dataset 类与批处理
PyTorch 训练需要一个 Dataset 类把文本和标签组织成批。清洗分词放在__getitem__里会让每个 epoch 都重复执行一遍,数据量超过几万条时开销很明显。更高效的做法是提前把所有文本转成 ids,存成.npy文件,训练时只读取整数序列。下面代码先给出能直接跑的版本:
import torch from torch.utils.data import Dataset, DataLoader class SentimentDataset(Dataset): def __init__(self, texts, labels, max_len=32): self.ids = [sentence_to_ids(t, max_len) for t in texts] self.labels = labels def __len__(self): return len(self.labels) def __getitem__(self, idx): return ( torch.tensor(self.ids[idx], dtype=torch.long), torch.tensor(self.labels[idx], dtype=torch.long) ) dataset = SentimentDataset(clean_texts, labels) loader = DataLoader(dataset, batch_size=4, shuffle=True) # 检查一个 batch 的形状 for batch_ids, batch_labels in loader: print("ids:", batch_ids.shape, "labels:", batch_labels.shape) break注意__getitem__返回的是(ids, label)元组,label 不是 one-hot,因为CrossEntropyLoss直接接收整数索引,转 one-hot 还要手动再换回来,多绕一圈。batch_size 在这里设 4 是为了方便观察,真实训练要开到 64 或 128。TextCNN 对 batch size 不敏感,但过小的 batch 会让 max pooling 的统计噪声变大,训练曲线抖动明显。
数据量上去之后,强烈建议把 ids 预先torch.save成文件。十万条数据的清洗分词一次要几十秒,十轮训练就重复十次,浪费的时间足够再调两轮参数。优化方向是用multiprocessing做并行编码,或者用datasets库先把文本字段持久化。
3.3 数据划分与标签泄露防护
模型评估需要严格的数据划分。很多人的流程是分词、建词表、再划分训练集和测试集,这其实是标签泄露的一个隐蔽来源:词表是在全量数据上统计的,测试集里出现的词已经参与过词频统计,等于测试时模型见过这些词的分布特征,指标会虚高。
正确顺序是:先按比例划分训练集、验证集、测试集,再只基于训练集构建词表。验证集和测试集里遇到词表外的词,统一编码为 UNK。用 scikit-learn 的例子:
from sklearn.model_selection import train_test_split # 先划分,再基于训练集构建词表 train_texts, temp_texts, train_labels, temp_labels = train_test_split( clean_texts, labels, test_size=0.3, random_state=42, stratify=labels ) val_texts, test_texts, val_labels, test_labels = train_test_split( temp_texts, temp_labels, test_size=0.5, random_state=42, stratify=temp_labels ) print(f"train: {len(train_texts)}, val: {len(val_texts)}, test: {len(test_texts)}")stratify=labels是关键参数。它保证正负样本比例在划分后保持一致。假设数据里好评占 90%,不做分层的话测试集可能全是好评,模型全判好评也有 90% 准确率,部署到线上以后差评一条都识别不出来。分层后每个集合都保持 9:1 的比例,后续评估才有参考价值。
另外,如果把sentence_to_ids的max_len在训练集和测试集之间改得不一样,也会引入一种隐蔽的不一致,这一点和词表不一致类似。max_len只影响截断位置,不应该成为训练和测试的差异点,固化成配置文件里的一个常量最稳。
4. 用 PyTorch 跑通 TextCNN:模型代码、训练参数与推理
4.1 TextCNN 模型实现
模型部分用nn.Module实现,核心是nn.Conv1d加nn.MaxPool1d。下面直接给完整可运行代码:
import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embedding_dim=128, num_filters=128, kernel_sizes=(2, 3, 4)): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) # 三种卷积核,分别捕捉 2-gram、3-gram、4-gram self.convs = nn.ModuleList([ nn.Conv1d(in_channels=embedding_dim, out_channels=num_filters, kernel_size=k, padding=k // 2) for k in kernel_sizes ]) # 拼接后过全连接,2 对应好评/差评两类 self.fc = nn.Linear(num_filters * len(kernel_sizes), 2) self.dropout = nn.Dropout(0.5) def forward(self, x): # x: (batch, seq_len) -> (batch, seq_len, embed) -> (batch, embed, seq_len) emb = self.embedding(x).permute(0, 2, 1) convs_out = [] for conv in self.convs: # 卷积后沿序列长度做最大池化,取最强烈信号 pooled = F.max_pool1d(F.relu(conv(emb)), kernel_size=emb.size(2)) convs_out.append(pooled.squeeze(2)) cat = torch.cat(convs_out, dim=1) return self.fc(self.dropout(cat))三个设计细节需要说明。第一,padding=k // 2做等长填充,让每个卷积核的输出长度都等于输入序列长度,避免了不同 kernel_size 输出长度不一致导致的拼接维度报错。如果不加 padding,kernel_size=4 的输出比输入短 3 个位置,和 2-gram 输出对不上,torch.cat直接抛错。第二,F.max_pool1d的池化窗口设为完整序列长度,每个卷积核的输出被压缩成一个标量,对应"整句话里最强烈的局部信号"。第三,padding_idx=0让 PAD 位置的向量在反向传播时保持零,不参与参数更新。漏掉这个参数,PAD 位置会被当成正常 token 训练,池化可能选中填充位产生不可解释的预测。
embedding_dim=128是短文本任务的标准起步值。如果句子平均长度超过 50,可以升到 200;如果只有二三十个字,128 够用。num_filters=128决定了全连接层的输入宽度,调大能略微提升拟合能力,但训练时间几乎线性增长。在 8 条演示数据上,参数稍大也无所谓,换成万级数据后建议从 64 起步观察。
4.2 训练循环与超参数设置
训练部分的完整循环如下:
import torch.optim as optim model = TextCNN(vocab_size=len(vocab)) optimizer = optim.Adam(model.parameters(), lr=0.001) criterion = nn.CrossEntropyLoss() train_dataset = SentimentDataset(train_texts, train_labels) val_dataset = SentimentDataset(val_texts, val_labels) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False) def evaluate(model, loader): model.eval() correct, total = 0, 0 with torch.no_grad(): for ids, label in loader: pred = model(ids).argmax(dim=1) correct += (pred == label).sum().item() total += label.size(0) return correct / total for epoch in range(10): model.train() total_loss = 0 for ids, label in train_loader: optimizer.zero_grad() logits = model(ids) loss = criterion(logits, label) loss.backward() optimizer.step() total_loss += loss.item() val_acc = evaluate(model, val_loader) print(f"epoch {epoch+1}: loss={total_loss:.4f}, val_acc={val_acc:.4f}")超参数的默认组合里,学习率 0.001 配合 Adam 在这个任务上几乎不用调;epoch 设 10 是小数据的稳妥值。TextCNN 收敛很快,一般 5 轮左右验证集准确率就到顶,后面几轮开始过拟合,表现为训练 loss 继续降、验证准确率不再涨。这里为了演示直接放开跑 10 轮,真实项目建议在每轮结束记录验证集准确率,连续三轮不提升就用早停退出。早停的阈值建议设为 0.001,太小会一直跑下去,太大会错过最佳点。
loss 下降但验证集准确率不涨,最常见的深层原因是类别不平衡。模型把多数类学得更好,少数类被牺牲,整体准确率还会缓慢上升。此时应该打印验证集每一类的 recall 和 precision,而不是只看 accuracy。如果确认少数类出问题,给CrossEntropyLoss传weight参数拉高少数类的梯度权重。
4.3 保存、加载与单条推理
训练完成后,模型权重、词表、超参数配置和max_len必须作为一个整体保存。单独存模型权重是常见的翻车现场:换环境推理时词表对不上,预测结果完全乱掉。
# 保存完整 checkpoint checkpoint = { 'model_state_dict': model.state_dict(), 'vocab': vocab, 'config': {'embedding_dim': 128, 'num_filters': 128, 'kernel_sizes': (2, 3, 4)}, 'max_len': 32 } torch.save(checkpoint, 'textcnn_sentiment.pt') # 加载模型并推理单条新文本 def predict(model, vocab, text, max_len=32): model.eval() ids = torch.tensor([sentence_to_ids(text, max_len)], dtype=torch.long) with torch.no_grad(): logits = model(ids) return logits.softmax(dim=1).tolist()[0] checkpoint = torch.load('textcnn_sentiment.pt') model.load_state_dict(checkpoint['model_state_dict']) probs = predict(model, checkpoint['vocab'], "房间不错但价格有点贵") print(f"好评概率: {probs[1]:.4f}, 差评概率: {probs[0]:.4f}")推理阶段的输出用概率而不是硬编码的 0/1。业务上经常需要自定义阈值:概率高于 0.6 判定为好评,低于 0.4 判定为差评,中间地带交给人工复核。固定 0.5 的默认边界在数据分布偏移时问题格外明显,这一点在最后一章还会展开。
保存路径建议带版本号,比如textcnn_sentiment_v1.pt。模型迭代时旧版本还能回滚,这也算给自己的后悔药。
5. 避坑记录:TextCNN 实战中反复出现的 5 个问题
5.1 坑一:验证集准确率 90%,上线后差评全被漏掉
现象:训练结果显示验证准确率 0.93,模型看起来一切正常。部署到线上后,差评几乎全被判断成好评。
原因:数据严重不平衡,好评占 90%,验证集准确率靠多数类撑着。模型把所有样本都判为好评也有 90% 准确率,但实际业务关注的少数类差评完全没有被学到。
解决:得分层划分加按类评估。用train_test_split(..., stratify=labels)保证正负比例一致,评估指标换成每一类的 recall、precision 和整体的 F1 macro,不要只看 accuracy。模型侧可以给CrossEntropyLoss传入weight参数,把差评的权重调高,比如weight = torch.tensor([0.4, 0.6])。这个坑的教训是:准确率只是一块遮羞布,业务指标才是最终验收线。
5.2 坑二:CUDA device-side assert 训练到一半中断
现象:训练在某个 batch 处报CUDA error: device-side assert triggered,进程直接退出或者卡死,后续代码看不到任何输出。
原因:标签里有 -1 或者 2。CrossEntropyLoss要求标签索引必须落在类别数范围内,模型输出维度是 2,索引只能取 0 或 1。清洗数据时常常有一行漏标或者多标的脏标签,混在几百条数据里极难定位。
解决:在 Dataset 的__getitem__里加防御性断言:
if self.labels[idx] not in (0, 1): raise ValueError(f"标签越界: {self.labels[idx]} at index {idx}")不要试图在训练循环里跟踪定位,数据一多根本找不到是哪一条。把断言写进 Dataset 是成本最低的防线,一次触发就能直接打印出索引和数据行。这个报错在 CPU 上运行时表现是 loss 变成 nan,转 CUDA 后才变成 device-side assert,排查方向一致。
5.3 坑三:同样的代码,换一台机器结果差 5%
现象:同一份代码在两个环境各跑一遍,验证集准确率一个 0.90,另一个 0.85,数据完全相同。
原因:随机性没有被固定。PyTorch 的 Embedding 默认随机初始化,DataLoader 的 shuffle 也有随机性。换机器后硬件指令集不同,浮点运算的舍入顺序也不同,结果自然有偏差。多数时候这个差异可以忽略,但要做基线对比或者给客户交付时,不稳定就是大问题。
解决:在训练脚本最前面固定三个随机源:
import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42) torch.cuda.manual_seed_all(42)需要注意,固定随机种子不代表完全消除浮点误差。跨硬件仍然会有微小差异,但已经把最大的随机因素抹平了。如果还要更严格,把 DataLoader 的generator也单独指定种子。
5.4 坑四:词表和推理阶段不一致,模型像坏了一样
现象:训练时指标正常,保存模型后重新加载,用训练集里的句子预测,概率输出每次都不一样,而且结果明显不合理,比如"太差了"被判成好评。
原因:推理脚本重新构建了词表。最常见的是用Counter重新统计词频后重新编号,而新文本进入统计会改变词频排序,同一个词在两个词表里的索引完全不同。模型加载的是旧索引对应的权重,输入却用的是新索引,预测自然错乱。
解决:把词表对象和模型权重一起存进 checkpoint,推理时直接从 checkpoint 读取,不允许在推理代码里重新构建词表。这个问题的隐蔽性在于代码不报错、不警告,完全表现为模型质量下降。遇到"已经跑通但推理结果诡异"的情况,先怀疑词表,再怀疑代码逻辑。这是我的血泪经验:这个坑刷掉了最多的排错时间,因为注意力完全不在数据链路上。
5.5 坑五:分词把"不怎么好吃"切成"不/怎么/好吃",情感判反
现象:一条真实点评"菜不怎么好吃",模型预测成好评。当把预处理后的输入打印出来,发现 jieba 切成了"不/怎么/好吃",3-gram 窗口同时看到了"怎么好吃"和"好吃",正向特征叠加后压倒了"不"的否定信号。
原因:jieba 在缺少上下文的情况下对否定结构切分不稳定。这是统计分词器的通病,不单是 jieba,换了别的分词器同样存在。否定词与中心词被拆开,卷积核捕获不到完整的否定语义。
解决:分词之后加一个否定词合并修复规则。扫描分词结果,遇到"不、不咋、不太、非、没"等否定词时,和后面一个词合并成一个 token。这个朴素规则对"不+形容词"这种高频模式效果立竿见影。更省事的路线是改用字级别输入,"不"作为单字绝不会被拆丢,否定结构天然保留在序列里,代价是词级语义要靠卷积重建,准确率在规范文本上会略低一些。实际项目往往是先跑字级别拿到稳定基线,再用词级别加规则去挑战更高准确率。
6. 验证与进阶:从 85% 到 90% 的四条实在路
如果前面流程都跑通,验证集准确率大概率在 80% 到 85% 之间。想再往上走,我试过几条真实有效的路。
第一条是字级别输入加多尺度卷积。字级别的词表只有几千,覆盖率高,对错别字和不规范口语鲁棒性更强。把 kernel_sizes 从 (2,3,4) 扩到 (2,3,4,5),能抓住四字组合和程度副词这类稍长信号,通常能涨 1 到 2 个点。代价是训练时间增加四分之一左右,值得换。
第二条是预训练词向量初始化。用大规模语料训好的 Word2Vec 或 Glove 中文词向量给 Embedding 做初始化,训练过程中继续微调,不冻结。数据量小于一万条时提升明显。冻结静态模式只适合语料领域高度一致的情况,否则冻结会把预训练语料的偏差也锁进模型,新领域的有效信息学不进来。
第三条是阈值调优而不是模型调优。跑完验证集后画出 PR 曲线,找 F1 值最高的概率阈值。默认 0.5 不是最优边界,实测里 0.55 到 0.6 之间经常是更好的切分点。实现很简单:把验证集的所有概率存下来,遍历 0.3 到 0.7 每个阈值算一遍 F1,取最大值对应的阈值作为线上标准。这一步不需要改模型结构,几分钟就能完成。
最后一件是给模型做冒烟测试。维护一份固定的边缘表达清单:"服务还行但价格太贵"、"没有想象中好,但也不算差"、"五星好评"、"送的赠品坏了"。如果前两条被单边判定,说明模型只学会了极端词特征,没学会转折结构。我现在每个训练版本都把这四条话术跑一遍,和准确率一起看,防止某些版本在指标好看的同时牺牲掉边界场景。TextCNN 在这个定位上不是最强的模型,但确实是最稳的。在资源不确定、延迟敏感、需要反复迭代的业务环境里,它的性价比很难被替代。希望这篇记录帮你在自己的数据上跑通第一版,后续再往 BERT、RoBERTa 迁移时,也有一个清晰的弱基线可以对照。
本文还有配套的精品资源,点击获取