简介:基于Pytorch实现的语音情感识别项目源码,面向机器学习开发者与语音技术研究人员,提供从语音降噪、特征提取到模型训练与推理的完整流程,适用于智能客服、心理健康辅助、人机交互等场景。压缩包共29个文件,包含25个Python脚本、1个YAML配置文件、1个Markdown说明、1张图片及1个TXT文本;脚本覆盖数据创建、特征抽取、模型定义、训练、评估与推理等模块,并采用LSTM、GRU等循环网络处理时序语音特征,结合反向传播与梯度下降完成参数优化。资源包仅94KB,轻量易读,目前已有577人学习。代码注释与使用文档齐全,目录结构清晰,既可作为课程设计或毕业设计的参考实现,也能帮助初学者快速理解情感分类的整体流程,是一份适合进阶学习的优质实战资源。
1. 从声波到情感:为什么语音情感识别比想象中更依赖特征工程
语音情感识别(Speech Emotion Recognition, SER)的目标是让机器从一段语音中判断说话人的情绪状态——愤怒、开心、悲伤、恐惧、平静等。很多初学者拿到 PyTorch 语音情感识别项目的第一反应是“直接上 Transformer”,但实际做下来会发现:情感识别准确率上不去的瓶颈通常不在模型结构,而在特征层面。原因是语音中的情感信息分布极为稀疏,一段 3 秒的音频里,真正承载情绪的可能是某个音节的基频抖动、某段频谱的能量骤变,或者句末的韵律拖着;而原始波形经过梅尔滤波器组后,这些信息可能被压缩到几乎不可见。
这意味着,一个可落地、可复现的语音情感识别项目,核心工作往往分三块:数据与标签的组织、声学特征的提取、模型对时序依赖的建模能力。本文将以 PyTorch 为框架,从零到一搭建一个可训练的语音情感识别流程,覆盖 RAVDESS、TESS 等常见数据集的目录组织、librosa 特征提取、CNN + LSTM 混合模型搭建、训练验证闭环,以及最后部署时要注意的种种边界问题。工程上,这个项目会让你的 GPU 真的有事情做,而不是把时间花在调参和 Debug 上。
2. 数据准备与特征抽取——语音情感识别的第一步
2.1 情感数据集的目录组织与标签编码
无论是 RAVDESS、TESS 还是 CASIA,市面上公开的语音情感数据集几乎都是以“文件夹 + 文件名”的形式存储元信息的。以 RAVDESS 为例,文件名是03-01-01-01-01-01-01.wav这样的格式,第 3 段数字(01)代表情感类别,第 4 段(01)代表情感强度。因此,写代码的第一步不是直接读音频,而是解析这些文件名。
import os import pandas as pd def parse_ravdess_filename(filename): parts = filename.replace('.wav', '').split('-') emotion_map = { '01': 'neutral', '02': 'calm', '03': 'happy', '04': 'sad', '05': 'angry', '06': 'fearful', '07': 'disgust', '08': 'surprised' } return { 'emotion': emotion_map.get(parts[2], 'unknown'), 'emotion_intensity': parts[3], 'file': filename } def build_dataset_df(root_dir): rows = [] for root, dirs, files in os.walk(root_dir): for f in files: if f.endswith('.wav') and f.startswith('03'): info = parse_ravdess_filename(f) info['path'] = os.path.join(root, f) rows.append(info) df = pd.DataFrame(rows) df['label_id'] = df['emotion'].astype('category').cat.codes return df df = build_dataset_df('RAVDESS/') print(df[['emotion', 'label_id', 'path']].head())这段代码完成两件事:从文件名解析出情感标签,并将情感类别映射为整数 ID 供 PyTorch 的交叉熵损失使用。有个非常容易被忽略的坑:astype('category').cat.codes生成的编码是按字母排序的,angry是 0,calm是 1,以此类推。这意味着你的模型输出层神经元数量和类别数量必须一致,且预测反向映射时要用同一个映射表,否则训练完拿到一个label_id根本不知道对应哪种情感。建议额外保存一份映射字典:
emotion_codes = df[['emotion', 'label_id']].drop_duplicates() emotion_dict = dict(zip(emotion_codes.label_id, emotion_codes.emotion)) print(emotion_dict)2.2 用 librosa 抽取 MFCC 与韵律特征
MFCC(梅尔频率倒谱系数)是语音情感识别中最常用的底层特征,但它有一个众所周知的短板:它主要用于刻画音色和声道形状,对语音情感的韵律变化(如音高起伏、语速快慢)不敏感。语音情感识别的工程实践中,常见的做法是将 MFCC、基频(F0)和短时能量一起送入模型。这里给出一个完整的特征提取函数:
import librosa import numpy as np def extract_features(audio_path, n_mfcc=40, max_len=128): y, sr = librosa.load(audio_path, sr=16000) # 统一长度:不足补零,超长截断 if len(y) > sr * 4: y = y[:sr * 4] else: y = np.pad(y, (0, sr * 4 - len(y)), 'constant') mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=n_mfcc, n_fft=512, hop_length=256) # 加入一阶差分,捕捉时序变化趋势 mfcc_delta = librosa.feature.delta(mfcc) # 基频和能量 f0, voiced_flag, _ = librosa.pyin(y, sr=sr, fmin=50, fmax=400) f0 = np.nan_to_num(f0) rms = librosa.feature.rms(y=y, hop_length=256)[0] # 将所有特征对齐到同一时间轴 n_frames = mfcc.shape[1] f0_resized = np.interp(np.linspace(0, len(f0) - 1, n_frames), np.arange(len(f0)), f0) rms_resized = np.interp(np.linspace(0, len(rms) - 1, n_frames), np.arange(len(rms)), rms) features = np.vstack([mfcc, mfcc_delta, f0_resized, rms_resized]) # 归一化 features = (features - np.mean(features, axis=1, keepdims=True)) / (np.std(features, axis=1, keepdims=True) + 1e-6) return features[:, :max_len]为什么固定为 4 秒?因为 RAVDESS 每个音频样本大约 3 到 4 秒,而 TESS 则更短。固定长度是为了让 batch 训练时张量维度一致,避免额外做 padding mask 的麻烦。MFCC 采用 40 维、每帧 25ms(hop 256 / 16000 = 16ms),128 帧覆盖约 2 秒的音频内容,在多数数据集上信息够用。
参数说明:sr=16000是绝大多数 SER 基准测试的采样率,比原始 wav 的 44.1kHz 小很多,好处是特征计算快、显存占用低,而且 16kHz 已经覆盖人类语音的绝大部分频率范围。log mel spectrogram也是很好的替代方案,但 MFCC 在特征维度和跨数据集泛化上有优势。
2.3 PyTorch Dataset 与 DataLoader 的工程实现
写数据的正确方式是把特征提取放在 Dataset 的__getitem__里做实时计算,而不是提前把所有特征缓存到内存。原因是特征数据量大,一个 RAVDESS 数据集就有 2000 多个 wav 文件,每个特征矩阵约 42 × 128 = 5376 个 float,全部加载也不过 40MB,貌似不大;但如果你用更大数据集(如 IEMOCAP),实时抽取能避免占用大量内存。此外,实时抽取允许在训练时做在线数据增强。
import torch from torch.utils.data import Dataset, DataLoader class EmotionDataset(Dataset): def __init__(self, df, augment=False): self.df = df self.augment = augment def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] features = extract_features(row['path']) # 数据增强:高斯噪声 if self.augment: noise = np.random.normal(0, 0.005, features.shape) features += noise tensor = torch.FloatTensor(features) label = torch.LongTensor([row['label_id']])[0] return tensor.unsqueeze(0), label train_loader = DataLoader( EmotionDataset(train_df, augment=True), batch_size=32, shuffle=True, num_workers=4, pin_memory=True )提示:tensor.unsqueeze(0)是为了给 CNN 增加通道维度,得到(1, 42, 128)的张量。num_workers设置为 4 到 8 能有效利用多核 CPU 做特征提取,否则 GPU 会在每个 batch 之间干等。
3. 搭建 CNN + LSTM 混合模型——捕捉情感的时间动态
3.1 时序建模的必要性:为什么不用纯 CNN 或纯 LSTM
语音情感不是瞬间的单一事件,而是持续数百毫秒甚至数秒的状态变化。一段从平静到愤怒的语音,其频谱特征是渐变而非突变的。纯 CNN 通过卷积核捕捉局部频谱模式,但感受野有限,需要堆叠很多层才能看到长程依赖;纯 LSTM 虽然能建模长时间状态,但对局部频谱特征的提取效率低,直接吃原始 MFCC 容易过拟合。
强基线是时间维度上的 CNN + LSTM 串联架构:先用两层卷积在频域和时域上做局部特征提取,再压缩频率轴,把时序轴保留作为 LSTM 的输入序列。这样 LSTM 的每个时间步接收的是 CNN 从该时刻多个频带聚合出来的特征向量,既有频率上的归纳偏置,又有时序上的长程建模。
3.2 PyTorch 实现:Conv2d + BiLSTM + Attention
以下是一个在日本语音情感挑战赛(JSE)和 RAVDESS 上都能快速收敛的基线结构:
import torch.nn as nn import torch.nn.functional as F class EmotionNet(nn.Module): def __init__(self, n_classes=8, input_channels=1, freq_dim=42, hidden_size=128): super().__init__() # 卷积块 1: 提取局部声学模式 self.conv1 = nn.Sequential( nn.Conv2d(input_channels, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(kernel_size=2, stride=2) # 42x128 -> 21x64 ) # 卷积块 2: 高维特征 self.conv2 = nn.Sequential( nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(kernel_size=2, stride=2) # 21x64 -> 10x32 ) self.lstm_input_dim = 64 * 10 # 64通道 * 频率轴剩余维度 self.lstm = nn.LSTM( input_size=self.lstm_input_dim, hidden_size=hidden_size, num_layers=2, batch_first=True, bidirectional=True, dropout=0.3 ) self.attention = nn.Sequential( nn.Linear(hidden_size * 2, hidden_size), nn.Tanh(), nn.Linear(hidden_size, 1) ) self.classifier = nn.Linear(hidden_size * 2, n_classes) def forward(self, x): batch_size, _, freq, time = x.shape x = self.conv1(x) x = self.conv2(x) # 合并通道和频率维度,保留时间维度 x = x.permute(0, 3, 1, 2).contiguous() batch_size, seq_len, c, f = x.shape x = x.view(batch_size, seq_len, c * f) lstm_out, _ = self.lstm(x) # (batch, seq_len, hidden*2) # 注意力权重 attn_w = torch.softmax(self.attention(lstm_out).squeeze(-1), dim=1) # 加权求和 context = torch.bmm(attn_w.unsqueeze(1), lstm_out).squeeze(1) return self.classifier(context) def predict(self, x): with torch.no_grad(): logits = self.forward(x) return torch.argmax(logits, dim=1)这段代码里最容易出错的是view的维度顺序:x.permute(0, 3, 1, 2)把张量从(batch, channels, freq, time)变为(batch, time, channels, freq),然后合并channels和freq,确保时间步作为 LSTM 的序列维度。
CNN 部分的设计逻辑如下:第一层MaxPool2d(2, 2)将频率轴从 42 减半到 21,时域从 128 减半到 64;第二层池化后频率轴变为 10,时域变为 32。此时LSTM时间步只有 32 个,对 4 秒语音来说每个时间步代表约 125ms 的音频段,信息粒度合理。如果去掉 CNN 直接让 LSTM 吃 128 个 MFCC 帧,序列过长会导致反向传播梯度衰减,且训练速度慢得多。
注意力机制在这里的作用:不同帧的情感载荷不同,比如“高兴”的峰值通常在语句后半段,而“愤怒”的频谱特征在前半段就饱和。注意力让模型学会动态加权这些帧,而不只是依赖 LSTM 最后时刻的隐藏状态。
3.3 类别不均衡的处理
语音情感数据集几乎都有类别不均衡。RAVDESS 里有 8 类情感,分布相对均匀;但 TESS 有高比例的“中性”样本;IEMOCAP 里“愤怒”样本明显少于其他类别。常见做法是在损失函数里用torch.nn.CrossEntropyLoss(weight=class_weights)加权,权重等于样本数的倒数归一化:
from sklearn.utils.class_weight import compute_class_weight classes = np.array(sorted(train_df['emotion'].unique())) weights = compute_class_weight('balanced', classes=classes, y=train_df['emotion']) class_weights = torch.FloatTensor(weights).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)实现时注意compute_class_weight返回的权重顺序必须与classes的顺序一致,而模型的输出层是按label_id排列的,二者必须严格对齐。一个稳妥的做法是拿train_df['label_id']直接计算:
weights = compute_class_weight( 'balanced', classes=np.arange(len(classes)), y=train_df['label_id'].values )4. 训练闭环与验证——从损失曲线到混淆矩阵
4.1 训练脚本的标准结构
训练部分的代码模式在 PyTorch 项目中高度相似,但有几个值得注意的细节:梯度裁剪、学习率调度、早停和最佳模型保存。语音任务中 LSTM 容易出现梯度爆炸,clip_grad_norm_是必选项。
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR from sklearn.metrics import accuracy_score, confusion_matrix, classification_report def train_one_epoch(model, loader, optimizer, criterion, device, clip=1.0): model.train() total_loss = 0 all_preds, all_labels = [], [] for x, y in loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() logits = model(x) loss = criterion(logits, y) loss.backward() # 关键:梯度裁剪,防止 LSTM 梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm=clip) optimizer.step() total_loss += loss.item() * x.size(0) all_preds.extend(torch.argmax(logits, dim=1).cpu().numpy()) all_labels.extend(y.cpu().numpy()) return total_loss / len(loader.dataset), accuracy_score(all_labels, all_preds) def evaluate(model, loader, criterion, device): model.eval() total_loss = 0 all_preds, all_labels = [], [] with torch.no_grad(): for x, y in loader: x, y = x.to(device), y.to(device) logits = model(x) loss = criterion(logits, y) total_loss += loss.item() * x.size(0) all_preds.extend(torch.argmax(logits, dim=1).cpu().numpy()) all_labels.extend(y.cpu().numpy()) return total_loss / len(loader.dataset), accuracy_score(all_labels, all_preds)训练主循环采用 50 个 epoch、AdamW 优化器、初始学习率1e-3、CosineAnnealingLR衰减到1e-5,并监控验证集准确率保存最优模型:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = EmotionNet(n_classes=len(df['emotion'].unique())).to(device) optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-5) criterion = nn.CrossEntropyLoss(weight=class_weights.to(device)) best_acc = 0 for epoch in range(50): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) scheduler.step() if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pth') print(f'Epoch {epoch}: val_acc improved to {best_acc:.4f}') else: print(f'Epoch {epoch}: val_acc {val_acc:.4f}, no improvement')4.2 验证策略:按说话人划分而不是按文件划分
这是很多语音情感识别项目最大的坑。如果直接train_test_split随机划分音频文件,同一说话人的不同录音会同时出现在训练集和测试集,模型可能记住说话人的声纹特征而非情感特征,测试准确率虚高。如果测试集里出现的新说话人,准确率会大幅下降。
正确做法是按说话人(actor)划分数据。RAVDESS 中文件名第 7 段是 actor ID,TESS 中是说话人首字母,IEMOCAP 中是 session 号。确保同一说话人的所有样本只出现在一个集合中:
df['actor'] = df['path'].apply(lambda p: os.path.basename(p).split('-')[-1].replace('.wav', '') if 'RAVDESS' in p else None) # 简化处理:按 actor 取前 80% 作为训练说话人 actors = df['actor'].unique() train_actors = actors[:int(len(actors) * 0.8)] val_actors = actors[int(len(actors) * 0.8):] train_df = df[df['actor'].isin(train_actors)] val_df = df[df['actor'].isin(val_actors)]如果不做这个划分,训练出来的模型其实是上有说话人特征的“作弊模型”,工程上毫无价值。
4.3 混淆矩阵与错误样本分析
准确率只是第一步,要看模型到底把哪两类情感搞混了。典型情况是“calm”和“neutral”几乎无法区分,因为二者的声学差异确实小;而“disgust”常常被误判为“angry”。通过混淆矩阵能快速定位这些问题。
import matplotlib.pyplot as plt import seaborn as sns # val_loader 上收集预测 all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for x, y in val_loader: x = x.to(device) preds = torch.argmax(model(x), dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(y.numpy()) cm = confusion_matrix(all_labels, all_preds) plt.figure(figsize=(10, 8)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=emotion_names, yticklabels=emotion_names) plt.xlabel('Predicted') plt.ylabel('True') plt.tight_layout() plt.savefig('confusion_matrix.png', dpi=150)然后针对混淆严重的类别,回去听那些样本。你会发现“calm”误判为“neutral”的样本大多语速偏快、基频偏高;“angry”误判为“happy”的可能是说话人带着上扬语调。这些定性结论会反哺特征设计。
5. 推理管线与多文件批处理落地
5.1 从 .pth 到可用的情感识别函数
训练完成后,把推理逻辑封装成一个独立的函数。这里有一个关键工程细节:特征提取必须和训练时保持完全一致——同样的n_mfcc、同样的max_len、同样的归一化方式。有一个不易察觉的雷是librosa.load默认的mono=True会混音双声道为单声道,如果音频本身已经是单声道,没问题;但如果是手机录的双声道,直接加载会导致音量减半,特征分布偏移。
import warnings def predict_emotion(audio_path, model, device, emotion_dict, n_mfcc=40, max_len=128): model.eval() features = extract_features(audio_path, n_mfcc=n_mfcc, max_len=max_len) tensor = torch.FloatTensor(features).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): logits = model(tensor) proba = torch.softmax(logits, dim=1) pred_id = torch.argmax(proba, dim=1).item() confidence = proba[0][pred_id].item() return emotion_dict[pred_id], confidence, proba.cpu().numpy()[0]这个函数返回三个值:预测的情感标签、置信度、全部类别的概率分布。置信度在工程上很有用,因为低于某个阈值(比如 0.4)的时候应该标记为“不确定”,而不是硬给一个结果。语音情感本身就有模糊性,让系统承认不知道比强行分类更专业。
5.2 批量处理文件夹中的音频
实际应用中,你不会只有一个 wav 文件,而是一整个目录。批量处理时最容易出问题的是不同音频采样率不一致。librosa.load内部会重采样到指定sr,但如果音频本身是低采样率(如 8kHz 电话语音),直接重采样到 16kHz 并不会恢复缺失的高频信息,MFCC 的高阶系数会失真。此时建议模型重新训练时加入 8kHz 音频做适配,而不是硬push 推理。
def batch_predict(folder_path, model, device, emotion_dict): results = [] for root, _, files in os.walk(folder_path): for fname in sorted(files): if not fname.endswith('.wav'): continue path = os.path.join(root, fname) try: emotion, conf, _ = predict_emotion(path, model, device, emotion_dict) results.append({'file': path, 'emotion': emotion, 'confidence': round(conf, 4)}) except Exception as e: print(f'Error processing {path}: {e}') return pd.DataFrame(results) df_results = batch_predict('audio_files/', model, device, emotion_dict) df_results.to_csv('predictions.csv', index=False)这个函数提供可靠的容错机制:单个音频文件损坏(如截断的 wav)不会让整个批次中断,错误信息打出来方便定位。
5.3 长音频切片与流式推理
如果输入是一段 1 分钟长的访谈录音,直接喂给模型是不行的——模型只见过 4 秒以内的样本。常见做法是滑窗切片:用 4 秒窗口、2 秒步长滑动,对每个切片预测情感,然后聚合。聚合方式有两种:硬投票(majority voting)和软投票(平均概率)。实践中软投票效果好于硬投票,因为能保留置信度信息。
def predict_long_audio(audio_path, model, device, emotion_dict, window=4, step=2): y, sr = librosa.load(audio_path, sr=16000) window_len = sr * window step_len = sr * step prob_sum = None count = 0 for start in range(0, len(y) - window_len + 1, step_len): segment = y[start:start + window_len] # 直接调用 extract_features 的变体,输入是 np.ndarray features = extract_features_from_array(segment, sr) tensor = torch.FloatTensor(features).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): prob = torch.softmax(model(tensor), dim=1).cpu().numpy()[0] prob_sum = prob if prob_sum is None else prob_sum + prob count += 1 avg_proba = prob_sum / count pred_id = np.argmax(avg_proba) return emotion_dict[pred_id], avg_proba[pred_id]切片的 step 决定了预测的时间分辨率,也影响计算量。step=2 意味着 1 分钟音频产生约 29 个窗口,每个窗口一次 forward,CPU 上大约几十毫秒,完全可接受。
6. 边界排查与效果提升的两个实操技巧
6.1 八分类硬调不上去时,先看噪声增强和幅值归一化
在 8 分类的 RAVDESS 任务上,CNN-LSTM 基线通常能到 65% 到 75% 的准确率。如果你的模型卡在 60% 以下,先检查特征提取时的幅值归一化。librosa.load返回的音频幅值范围约为 -1 到 1,但不同录音的响度差异大,直接抽取 MFCC 会让模型对音量敏感。简单有效地做法是做短时能量归一化:
def normalize_loudness(y): peak = np.abs(y).max() if peak > 0: y = y / peak return y另一个高频踩坑是数据增强时机不对。如果对整段音频做噪声增强,某些低能量情感的 MFCC 帧会被淹没,导致模型直接学成“噪声分类器”。推荐把噪声幅度系数设在0.001到0.01之间,且只做训练时增强,推理时禁掉。
6.2 用 SpecAugment 的频域掩码替代高斯噪声,效果更稳
高斯噪声会破坏所有频带,而语音情感的关键频带通常集中在中低频。一种在语音领域更常用且稳定的技巧是 SpecAugment 风格的时间/频率掩码——在 MFCC 的特征矩阵上随机抹掉一个频带或时间段。这样模型不会依赖固定频段,泛化性更好:
def spec_augment(features, freq_mask=4, time_mask=8): features = features.copy() n_freq, n_time = features.shape # 随机频带掩码 f0 = np.random.randint(0, n_freq - freq_mask) features[f0:f0 + freq_mask, :] = 0 # 随机时间掩码 t0 = np.random.randint(0, n_time - time_mask) features[:, t0:t0 + time_mask] = 0 return features在 Dataset 的__getitem__中调用spec_augment与高斯噪声轮换使用,能显著缓解过拟合。注意掩码值设为 0 会改变特征分布,因此训练和推理的特征归一化方式应当一致;如果归一化在掩码之前做,掩码区域保持 0 是合理的。
6.3 模型集成:票数不够时靠平均概率
单一模型在情感识别上通常波动较大,尤其是在验证集中的新说话人上。工程上最稳定的提升手段是训练 3 到 5 个不同随机种子的模型,推理时把概率做算术平均。下面给出代码骨架:
def ensemble_predict(audio_path, model_paths, device, emotion_dict): prob_sum = None for path in model_paths: model = EmotionNet(n_classes=len(emotion_dict)).to(device) model.load_state_dict(torch.load(path, map_location=device)) model.eval() _, _, prob = predict_emotion_proba(audio_path, model, device) prob_sum = prob if prob_sum is None else prob_sum + prob avg_proba = prob_sum / len(model_paths) return emotion_dict[np.argmax(avg_proba)], np.max(avg_proba)集成不仅在准确率上平均提升 2 到 3 个百分点,更重要的是置信度校准变稳定——单一模型可能对某些样本给出 0.9 的置信度但预测错误,集成后概率分布更平滑,错误样本的置信度会显著下降。这让阈值判断变得更有意义。测试时用torch.load(path, map_location='cpu')再.to(device),避免在 GPU 上直接加载导致显存峰值翻倍。这个技巧在显存有限的推理机器上非常实用。
本文还有配套的精品资源,点击获取