python
复制代码
"""
实验3: TextRNN (BiLSTM + Attention) 文本分类 (exp3)
====================================================
"""
import os, json, time, random, warnings
warnings.filterwarnings('ignore')
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
from sklearn.metrics import f1_score, accuracy_score, classification_report
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, 'data')
LOGS_DIR = os.path.join(BASE_DIR, 'logs')
MODELS_DIR = os.path.join(BASE_DIR, 'models')
SUBMISSIONS_DIR = os.path.join(BASE_DIR, 'submissions')
for d in [LOGS_DIR, MODELS_DIR, SUBMISSIONS_DIR]:
os.makedirs(d, exist_ok=True)
LABEL_MAP = {0: '科技', 1: '股票', 2: '体育', 3: '娱乐', 4: '时政',
5: '社会', 6: '教育', 7: '财经', 8: '家居', 9: '游戏',
10: '房产', 11: '时尚', 12: '彩票', 13: '星座'}
SEED = 42
MAX_LEN = 256
VOCAB_SIZE = 7550
EMBED_DIM = 100
# 【核心参数大换血】
HIDDEN_SIZE = 128 # LSTM 单向隐藏层维度。因为是双向(Bi),最终特征会变成 128 * 2 = 256 维。
NUM_LAYERS = 2 # 堆叠两层 LSTM,让模型能学到更深层的逻辑。
DROPOUT = 0.15
NUM_CLASSES = 14
BATCH_SIZE = 16 # 【注意】RNN 极其吃显存,所以 Batch Size 从 128 降到了 16!
LR = 2e-4
EPOCHS = 10
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
def set_seed(seed):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
set_seed(SEED)
# ==========================================
# 关卡一:带有"真实长度"记录仪的数据加工厂
# ==========================================
class NewsDataset(Dataset):
def __init__(self, texts, labels=None, max_len=MAX_LEN):
# 【招募库管员】
# 把所有新闻文本(长串的数字字符串)和标签存进仓库
self.texts = texts
self.labels = labels
self.max_len = max_len # 规定流水线上的标准包装盒大小(256)
def __len__(self):
# 【清点库存】
# 告诉 DataLoader,我们仓库里总共有多少篇新闻
return len(self.texts)
def __getitem__(self, idx):
# 【核心加工流水线】:当 DataLoader 来要第 idx 篇新闻时,现场加工
# 1. 拆解零件:把 "345 889 12" 这种整块的字符串,切成一个个整数组成的列表
tokens = [int(t) for t in self.texts[idx].split()]
# ---------------------------------------------------------
# 🌟【全场最核心的改动:记录真实长度】🌟
# ---------------------------------------------------------
# 为什么用 min?
# 如果句子长度是 50,min(50, 256) = 50。真实长度就是 50。
# 如果句子长度是 300,超过了盒子大小,一会儿要被切掉,所以它的有效真实长度最多只能算作 256。
# 【面试防坑】:这个 length 极其重要!它要被送给后方的 LSTM,告诉它"算到这个长度就赶紧停下,后面的全是 0,不用看了!"
length = min(len(tokens), self.max_len)
# todo:GPU 是一个极其死板的"流水线工人",它根本不懂什么是"句子",它只认识方方正正的矩阵
# 所以固定长度
# 2. 截断与填充 (把残次品变成标准件)
if len(tokens) > self.max_len:
# 太长的,一刀切掉尾巴
tokens = tokens[:self.max_len]
else:
# 太短的,屁股后面全塞上 0 (Padding)
tokens = tokens + [0] * (self.max_len - len(tokens))
# 3. 装车打包:转换成 PyTorch 喜欢的 Tensor (长整型)矩阵
x = torch.tensor(tokens, dtype=torch.long)
# 4. 发货:带着"小尾巴"出厂
if self.labels is not None:
# 训练集:不仅返回标准件 x,返回标签 y,还把【真实长度 length】一起打包发走!
return x, torch.tensor(self.labels[idx], dtype=torch.long), length
# 测试集:没有标签,但依然要带上【真实长度 length】
return x, length
# ==========================================
# 关卡三:划重点的神器 (Attention 机制)
# ==========================================
class Attention(nn.Module):
def __init__(self, hidden_size):
super().__init__()
# 【招募阅卷老师】
# hidden_size 在这里是 256 (因为是双向 LSTM,128 * 2)
# 1. 投影矩阵 W:相当于给阅卷老师配了一副"透视眼镜"
self.W = nn.Linear(hidden_size, hidden_size)
# 2. 核心评价向量 v:相当于阅卷老师脑子里的"标准答案"
# nn.Parameter 意味着这是一个可以随着训练不断优化的参数,模型会自己学到什么特征最重要。
self.v = nn.Parameter(torch.randn(hidden_size))
def forward(self, h, mask=None):
# 【开始阅卷】
# 假设当前输入 h (Bi-LSTM的输出) 的形状是: [16, 256, 256]
# 代表 16句话,每句话 256个词,每个词已经被提炼成了 256维 的高级特征。
# ---------------------------------------------------------
# 第一步:给每个词打初评分
# ---------------------------------------------------------
# 先让 256 维的特征戴上透视眼镜 (W),然后用 tanh 激活函数把数值压缩到 -1 到 1 之间
score = torch.tanh(self.W(h))
# 让处理过的特征,去和老师脑子里的标准答案 (v) 算内积 (matmul)
# 内积越大,说明这个词的特征和标准答案越匹配,分数越高!
# 形状突变: [16, 256, 256] 和 [256] 相乘 -> [16, 256] (每句话里的 256 个词,各自得到了一个具体的分数)
score = torch.matmul(score, self.v)
# ---------------------------------------------------------
# 第二步:极其冷酷的 Mask (遮蔽废话)
# ---------------------------------------------------------
if mask is not None:
# 面试官必考:为什么要填 -1e9 (负十亿)?
# 答:因为后面要过 Softmax。补齐的 0 没有任何语义,如果初评分数是 0,过 Softmax 后依然会分走一部分权重。
# 把补齐位置的分数强行改成 -1e9,Softmax(-1e9) 会无限趋近于绝对的 0。彻底封杀废话的发言权!
score = score.masked_fill(~mask, -1e9)
# ---------------------------------------------------------
# 第三步:转化为重要性百分比 (权重分配)
# ---------------------------------------------------------
# softmax 会把 256 个词的分数,变成加起来等于 100% 的概率分布。
# 比如"科技"占 80%,"发布"占 15%,标点符号占 0%。
# unsqueeze(2) 是为了补全维度,方便后面做乘法。形状突变: [16, 256] -> [16, 256, 1]
attn = torch.softmax(score, dim=1).unsqueeze(2)
# ---------------------------------------------------------
# 第四步:加权融合 (提取终极精华)
# ---------------------------------------------------------
# h [16, 256, 256] * attn [16, 256, 1]
# 这一步极其优美:把每个词的 256 维特征,乘以它分配到的权重。废话乘以 0 直接消失,关键词乘以大比重被无限放大。
# 最后用 sum(dim=1) 把这 256 个加权后的词压缩融合成 1 个向量!
# 形状突变: [16, 256, 256] -> [16, 256]
out = (h * attn).sum(dim=1)
return out # 吐出这 16 句话的终极浓缩精华!
# ==========================================
# 终极缝合怪:TextRNN (BiLSTM + Attention) 主干网络
# ==========================================
class TextRNN(nn.Module):
def __init__(self):
super().__init__()
# 1. 词嵌入层 (查字典):7550个词,每个词变100维向量。遇到0(补齐位)直接输出全0。
# 75500*100
self.embedding = nn.Embedding(VOCAB_SIZE, EMBED_DIM, padding_idx=0)
# 2. 核心大闸:双向 LSTM (Bi-LSTM)
# EMBED_DIM: 输入维度(100)
# HIDDEN_SIZE: 隐藏层维度(128)。因为开了双向(bidirectional=True),输出维度会翻倍变成 256。
# batch_first=True: 告诉 PyTorch 输入的第一维是 batch_size [batch, seq_len, feature]
self.lstm = nn.LSTM(EMBED_DIM, HIDDEN_SIZE, NUM_LAYERS,
batch_first=True, bidirectional=True,
dropout=DROPOUT if NUM_LAYERS > 1 else 0)
# 3. 注意力机制 (划重点):输入是 Bi-LSTM 的输出维度 (128 * 2 = 256)
self.attention = Attention(HIDDEN_SIZE * 2)
# 4. 随机失活防过拟合
self.dropout = nn.Dropout(DROPOUT)
# 5. 最终法官 (全连接层):把提炼出的 256维 精华,映射到 14个 新闻类别上打分
self.fc = nn.Linear(HIDDEN_SIZE * 2, NUM_CLASSES)
def forward(self, x, lengths=None):
# 假设 x 的形状: [16, 256] (16句话,每句强行Pad到了256长)
# 【极其关键的 Mask 掩码生成】
# 找出 x 里不是 0 的真实单词位置,标为 True;是 0 的补齐位,标为 False。
# 这个 mask 后面会送给 Attention,用来屏蔽掉补齐位的注意力打分。
mask = (x != 0)
# 查字典:形状突变 [16, 256] -> [16, 256, 100]
x = self.embedding(x)
x = self.dropout(x)
# 【面试高光时刻:动态序列打包】
if lengths is not None:
# 第一步:真空压缩 (剥离所有的 Padding 0)
# 把原本方方正正带有海量 0 的矩阵,按真实长度压缩成极其紧凑的 PackedSequence 对象。
packed = nn.utils.rnn.pack_padded_sequence(
x, lengths.cpu().clamp(min=1), batch_first=True, enforce_sorted=False)
# 第二步:纯净版 LSTM 计算 (速度极快,且记忆不会被 0 污染)
out, _ = self.lstm(packed)
# 第三步:充气解压 (把压缩包还原成方阵)
# 此时的 out 恢复成了 [16, 256, 256]。原来补 0 的位置,现在的特征依然是 0。
out, _ = nn.utils.rnn.pad_packed_sequence(out, batch_first=True, total_length=mask.size(1))
else:
# 裸考模式:如果没有提供真实长度,只能带 0 傻算(慢且效果差)
out, _ = self.lstm(x)
# 经过 LSTM 后,out 形状: [16, 256, 256]
# 把带有 256 步特征的 out 和刚才生成的 mask 交给 Attention 老师划重点。
# Attention 老师会把这 256 个词的特征,按重要性加权浓缩成 1 个特征!
# 形状突变: [16, 256, 256] -> [16, 256]
out = self.attention(out, mask)
out = self.dropout(out)
# 最终审判:输出 14 个类别的原始打分 (Logits)
# 形状突变: [16, 256] -> [16, 14]
return self.fc(out)
# ==========================================
# 训练引擎与评估体系 (与 TextCNN 逻辑一致,但加入了 lengths)
# ==========================================
def get_class_weights(labels):
# 【劫富济贫】:统计各类别频次并取倒数,给样本极少的类(如星座)极大的 Loss 权重惩罚。
counts = np.bincount(labels, minlength=NUM_CLASSES)
weights = 1.0 / (counts + 1e-6)
weights = weights / weights.sum() * NUM_CLASSES # 均值归一化,防止 Loss 整体过小
return torch.tensor(weights, dtype=torch.float32)
# 关卡五:训练引擎 (核心进化的逻辑)
# ==========================================
def train_epoch(model, loader, optimizer, criterion, device):
# 【启动:进入战斗模式】
# 启用 Dropout 和 Batch Normalization,允许参数被更新。
model.train()
total_loss = 0
all_preds, all_labels = [], []
# 这里的 batch 通常包含 [x, y, lengths]
for batch in loader:
# ---------------------------------------------------------
# 第一步:数据搬运
# x: [16, 256], y: [16], lengths: [16] (假设 Batch=16)
# ---------------------------------------------------------
x, y, lengths = batch[0].to(device), batch[1].to(device), batch[2]
# ---------------------------------------------------------
# 第二步:清空记忆 (Gradient Zeroing)
# ---------------------------------------------------------
# PyTorch 的梯度是累加的!如果不洗干净"上一个 Batch"留下的梯度,
# 这次的更新就会乱套。就像炒新菜前必须洗锅。
optimizer.zero_grad()
# ---------------------------------------------------------
# 第三步:前向传播 (Forward)
# ---------------------------------------------------------
# logits 形状: [16, 14] (16 句话在 14 个类别上的裁判打分)
logits = model(x, lengths)
# ---------------------------------------------------------
# 第四步:计算痛苦值 (Loss Calculation)
# ---------------------------------------------------------
# 计算预测分数 logits 与真实标签 y 之间的差距。
# 这里用的是交叉熵损失 (CrossEntropyLoss),包含了类别加权。
loss = criterion(logits, y)
# ---------------------------------------------------------
# 第五步:反向传播与优化 (Backprop & Update)
# ---------------------------------------------------------
# 1. 甩锅:计算 loss 对模型每个参数的贡献(梯度)
loss.backward()
# 2. 安检(防止爆炸):
# 面试必考:梯度裁剪 (Gradient Clipping)。
# LSTM 很容易出现梯度爆炸,这行代码强行把过大的梯度限制在 5.0 以内。
nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
# 3. 进化:optimizer 顺着梯度方向,微调模型里的权重参数
optimizer.step()
# ---------------------------------------------------------
# 统计环节
# ---------------------------------------------------------
total_loss += loss.item() * x.size(0)
# argmax(1) 找出 14 个类里分最高的索引
all_preds.extend(logits.argmax(1).cpu().numpy())
all_labels.extend(y.cpu().numpy())
# 计算整轮的平均损失和 Macro-F1 (衡量不平衡类别的核心指标)
avg_loss = total_loss / len(loader.dataset)
f1 = f1_score(all_labels, all_preds, average='macro')
return avg_loss, f1
# ==========================================
# 关卡四:验证引擎 (推理与性能评估)
# ==========================================
@torch.no_grad() # 【省钱/省显存秘籍】
# 装饰器:告诉 PyTorch 彻底关闭"梯度追踪"。
# 在验证时我们不更新参数,不存梯度能让显存占用降低 50% 以上,速度也飞快。
def eval_epoch(model, loader, criterion, device):
model.eval() # 【重要状态切换】
# 将模型设为"评估模式"。这会影响 Dropout 层:
# 训练时:Dropout 随机丢弃神经元(为了抗过拟合)。
# 验证时:Dropout 必须关闭(100% 神经元在线),否则预测结果会随机摆动,不稳定。
total_loss = 0
all_preds, all_labels = [], []
for batch in loader:
# 1. 搬运数据到显卡
x, y, lengths = batch[0].to(device), batch[1].to(device), batch[2]
# 2. 前向传播:拿到那 14 个类别的"裁判评分" (Logits)
logits = model(x, lengths)
# 3. 计算损失:虽然不反向传播,但要看 Loss 降下来没有
loss = criterion(logits, y)
# 4. 统计总误差:loss.item() 拿到的单均值,要乘上当前 Batch 的样本数 x.size(0)
total_loss += loss.item() * x.size(0)
# 5. 记录预测结果:
# argmax(1):在 14 个分数里找最大的那个位置(索引)。
# .cpu().numpy():从显存里拉回到内存,变成普通的 numpy 数组方便存进列表。
all_preds.extend(logits.argmax(1).cpu().numpy())
all_labels.extend(y.cpu().numpy())
# 【核心指标计算】
# 1. 平均 Loss = 总 Loss / 样本总数
avg_loss = total_loss / len(loader.dataset)
# 2. Macro-F1 (宏平均 F1):
# 由于天池数据集类别极度不平衡(科技类多,星座类极少),
# 用 Macro-F1 能防止模型通过"只猜大类"来骗高分。它是对 14 个类分别算 F1 再取平均。
f1 = f1_score(all_labels, all_preds, average='macro')
# 3. Accuracy (准确率):最直观的指标,看看 100 篇新闻猜对了几个。
acc = accuracy_score(all_labels, all_preds)
return avg_loss, f1, acc, all_preds, all_labels
# ==========================================
# 关卡六:实战检阅 (推理/预测阶段)
# ==========================================
@torch.no_grad() # 【只读模式】
# 告诉 PyTorch:现在是纯推理阶段,千万不要计算梯度,把求导引擎锁死。
# 这样可以极大释放显存,防止在跑几万条测试数据时显存炸掉。
def predict(model, loader, device):
model.eval() # 【稳定模式】
# 必须关掉 Dropout。我们预测的时候需要模型给出最确定的判断,不能再随机丢弃神经元了。
all_preds = []
for batch in loader:
# 1. 搬运原材料:把测试集的新闻编号 x 送上显卡
x = batch[0].to(device)
# ---------------------------------------------------------
# 2. 核心细节:兼容性安检 (Compatibility Check)
# ---------------------------------------------------------
# 为什么会有这么绕的一行?
# - 训练集:通常返回 (x, y, lengths),len(batch) 是 3
# - 测试集:没有真实标签 y,通常只返回 (x, lengths),len(batch) 是 2
# 这行代码保证了:无论 y 在不在,我们都能准确抓到那个关键的 lengths。
lengths = batch[1] if len(batch) == 2 else batch[2]
# 3. 询问模型:这几篇新闻分别是什么类?
# logits 形状: [Batch, 14]
logits = model(x, lengths)
# 4. 做出最终裁决:
# argmax(1):在 14 个分数里找最大的那个。
# .cpu().numpy():把"裁判的判决"从显卡拉回内存,变成普通的数字数组。
all_preds.extend(logits.argmax(1).cpu().numpy())
return all_preds # 返回一个长长的列表,里面全是 0~13 的数字
def main():
# ---------------------------------------------------------
# 1. 实验初始化与环境打印
# ---------------------------------------------------------
print("=" * 60)
print(f"实验3: TextRNN BiLSTM+Attention (max_len={MAX_LEN}, device={DEVICE})")
print("=" * 60)
# ---------------------------------------------------------
# 2. 数据加载:从硬盘读取 CSV 文件
# ---------------------------------------------------------
train_df = pd.read_csv(os.path.join(DATA_DIR, 'train_split.csv'), sep='\t')
val_df = pd.read_csv(os.path.join(DATA_DIR, 'val_split.csv'), sep='\t')
test_df = pd.read_csv(os.path.join(DATA_DIR, 'test_a.csv'), sep='\t')
# ---------------------------------------------------------
# 3. 封装 Dataset:把 DataFrame 转为 PyTorch 能识别的 Dataset 对象
# ---------------------------------------------------------
train_ds = NewsDataset(train_df['text'].tolist(), train_df['label'].tolist())
val_ds = NewsDataset(val_df['text'].tolist(), val_df['label'].tolist())
test_ds = NewsDataset(test_df['text'].tolist()) # 测试集没标签
# ---------------------------------------------------------
# 4. 构造 DataLoader:开启多线程读取,pin_memory 加速数据从内存到显存的拷贝
# ---------------------------------------------------------
train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=4, pin_memory=True)
val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE * 2, shuffle=False, num_workers=4, pin_memory=True)
test_loader = DataLoader(test_ds, batch_size=BATCH_SIZE * 2, shuffle=False, num_workers=4, pin_memory=True)
# ---------------------------------------------------------
# 5. 模型实例化:并搬运到 GPU (DEVICE)
# ---------------------------------------------------------
model = TextRNN().to(DEVICE)
# 计算总参数量:看看你的显存花在哪了
param_count = sum(p.numel() for p in model.parameters())
print(f"模型参数量: {param_count / 1e6:.2f}M")
# ---------------------------------------------------------
# 6. 核心三件套:损失函数、优化器、学习率调度器
# ---------------------------------------------------------
# 计算类别权重:针对天池数据分布不均(科技多,星座少)进行的"补偿"
class_weights = get_class_weights(train_df['label'].values).to(DEVICE)
criterion = nn.CrossEntropyLoss(weight=class_weights) # 带权重的交叉熵
optimizer = torch.optim.Adam(model.parameters(), lr=LR)
# 余弦退火调度器:让学习率像波浪一样下降,有助于模型跳出局部最优解
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)
best_f1 = 0
history = [] # 用于记录每轮的 Loss 和 Acc,方便后续画图
# ---------------------------------------------------------
# 7. 核心训练循环 (Training Loop)
# ---------------------------------------------------------
for epoch in range(1, EPOCHS + 1):
t0 = time.time()
# 训练一轮
train_loss, train_f1 = train_epoch(model, train_loader, optimizer, criterion, DEVICE)
# 验证一轮
val_loss, val_f1, val_acc, val_preds, val_labels = eval_epoch(model, val_loader, criterion, DEVICE)
# 学习率更新
scheduler.step()
elapsed = time.time() - t0
print(f"Epoch {epoch:2d}/{EPOCHS} | "
f"train_loss={train_loss:.4f} train_f1={train_f1:.4f} | "
f"val_loss={val_loss:.4f} val_f1={val_f1:.4f} val_acc={val_acc:.4f} | "
f"time={elapsed:.1f}s")
# 保存训练历史
history.append({
'epoch': epoch, 'train_loss': train_loss, 'train_f1': train_f1,
'val_loss': val_loss, 'val_f1': val_f1, 'val_acc': val_acc
})
# 【模型存档】:如果这轮的 F1 分数最高,就存下"最强版本"
if val_f1 > best_f1:
best_f1 = val_f1
torch.save(model.state_dict(), os.path.join(MODELS_DIR, 'textrnn_best.pt'))
print(f" -> 保存最佳模型 (val_f1={best_f1:.4f})")
# ---------------------------------------------------------
# 8. 最终评估:加载表现最好的那个模型权重
# ---------------------------------------------------------
model.load_state_dict(torch.load(os.path.join(MODELS_DIR, 'textrnn_best.pt')))
_, final_f1, final_acc, val_preds, val_labels = eval_epoch(model, val_loader, criterion, DEVICE)
# 打印每个类别的详细表现 (F1, Precision, Recall)
per_class = f1_score(val_labels, val_preds, average=None)
per_class_dict = {LABEL_MAP[i]: float(f'{v:.4f}') for i, v in enumerate(per_class)}
print(f"\n最终验证集: macro-F1={final_f1:.4f}, accuracy={final_acc:.4f}")
print(classification_report(val_labels, val_preds,
target_names=[LABEL_MAP[i] for i in range(14)], digits=4))
# ---------------------------------------------------------
# 9. 高阶操作:保存 Logits (为模型融合做准备)
# ---------------------------------------------------------
model.eval()
val_logits_list = []
with torch.no_grad():
for batch in val_loader:
x, _, lengths = batch[0].to(DEVICE), batch[1], batch[2]
logits = model(x, lengths)
val_logits_list.append(logits.cpu())
# 拼接并保存验证集的原始分数(14维)
val_logits_np = torch.cat(val_logits_list, dim=0).numpy()
np.save(os.path.join(MODELS_DIR, 'textrnn_val_logits.npy'), val_logits_np)
# ---------------------------------------------------------
# 10. 测试集预测与提交文件生成
# ---------------------------------------------------------
test_logits_list = []
with torch.no_grad():
for batch in test_loader:
x = batch[0].to(DEVICE)
lengths = batch[1] if len(batch) == 2 else batch[2]
logits = model(x, lengths)
test_logits_list.append(logits.cpu())
test_logits_np = torch.cat(test_logits_list, dim=0).numpy()
np.save(os.path.join(MODELS_DIR, 'textrnn_test_logits.npy'), test_logits_np)
# 从 14 维分数取最大值,生成最终 CSV
test_preds = test_logits_np.argmax(axis=1)
sub_df = pd.DataFrame({'label': test_preds})
sub_df.to_csv(os.path.join(SUBMISSIONS_DIR, 'submission_textrnn.csv'), index=False)
print(f"-> logits已保存: textrnn_val_logits.npy, textrnn_test_logits.npy")
# ---------------------------------------------------------
# 11. 实验日志持久化:方便复盘和写论文
# ---------------------------------------------------------
result = {
'model': 'TextRNN_BiLSTM_Attention',
'history': history,
'per_class_f1': per_class_dict, # 记录模型在哪几个类分得不好
# ... 其他超参数 ...
}
with open(os.path.join(LOGS_DIR, 'exp3_textrnn_results.json'), 'w') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
# 将实验关键指标追加到 experiment_log.csv
# ... csv 保存逻辑 ...
print("\n[完成] 实验3 TextRNN完毕!")
if __name__ == '__main__':
main()