简介这是一套基于TensorFlow 2.0实现的自动语音识别工具聚焦THCHS30中文语音数据集面向需要快速上手中文ASR研究的开发者与学生。压缩包共118个文件约48.58MB包含wav语音样本、trn文本标注、Python源码、预训练权重h5/model/checkpoint以及train/dev/test数据划分csv可直接用于模型训练、验证与识别效果测试。包内已提供训练至251轮的模型权重配合评估代码可复现语音到文本的转换流程相关脚本与文档覆盖数据预处理、模型训练和部署等环节对理解深度学习语音识别链路有较大参考价值。目前已有259人学习下载适合具备一定Python与深度学习基础、希望借助现成工具和数据集开展中文语音识别实验的读者。1. 解压这份 ASRT 语音识别模型包之后先别急着跑如果你的压缩包里躺着speech_model251_e_0_step_625000.model.base、weight.ckpt.data-00000-of-00002、base_weight.h5和四个 CSV 文件那么这套资源基本可以确定是 ASRTAutomatic Speech Recognition Toolkit在 THCHS30 中文普通话数据集上训练到 step625000 时的快照。它不像普通软件装好就能用而是给你留了一个“半成品工程”模型文件负责恢复网络train.csv 负责喂数据dev/test.csv 负责验证指标。我的建议是不要上来就跑python test.py而是先搞清楚每个文件的角色再按“数据 → 恢复 → 评估 → 导出”的顺序拆解。这篇文章就是按这条路径整理的对正在用 TensorFlow 2.0 做中文语音识别的工程人员或者想把 THCHS30 训练结果拿去做二次开发的人都值得往下看。2. 数据划分与音频特征把 THCHS30 的 wav 和 trn 统一成 CSVASRT 项目的输入不是直接读音频目录而是要求一个 CSV 文件每一行包含音频路径, 文本标签。包里的train.csv / dev.csv / test.csv正是这种格式而data.csv是合并后的全量标注。很多人第一次拿到 THCHS30 会困惑数据集里的标签文件是.trn里面第一行是汉字第二行是拼音但 ASRT 只需要汉字而且不允许出现大写字母或编号。如果你直接把.trn的拼音行塞进 label词表会混入大量非汉字 token最后训练出来的模型永远出不了中文。所以第一步是从原始 THCHS30 目录生成干净的 CSV。2.1 从 THCHS30 原始目录生成 train/dev/test.csvTHCHS30 的原始目录结构通常是data/wav/下存放 A1、A2 等开头的 wav 文件data/trn/或相邻目录存放同名的.trn文本。.trn文件内容第一行是汉字标注第二行是拼音第三行有时是注音或空行。ASRT 训练只取第一行并且要去掉空白字符和标点。下面这段脚本会把整套数据按 ASRT 习惯整理成 CSVimport os import csv import re def clean_text(text): # 去掉拼音、数字、半角符号只保留中文字符 text text.strip().split(\n)[0] text re.sub(r[A-Za-z0-9\s\.\,\!\?\-\_\\], , text) return text def build_csv(wav_dir, trn_dir, out_csv): rows [] for fname in os.listdir(wav_dir): if not fname.endswith(.wav): continue base fname[:-4] trn_path os.path.join(trn_dir, base .trn) if not os.path.exists(trn_path): print(fmissing trn: {trn_path}) continue with open(trn_path, r, encodingutf-8) as f: label clean_text(f.read()) if len(label) 0: continue wav_path os.path.abspath(os.path.join(wav_dir, fname)) rows.append([wav_path, label]) # 按音频编号排序保证后续划分一致 rows.sort(keylambda x: x[0]) with open(out_csv, w, encodingutf-8, newline) as f: writer csv.writer(f) writer.writerow([wav_path, transcript]) writer.writerows(rows) # 示例分别生成 train/dev/test build_csv(data/wav, data/trn, train.csv)这里的clean_text会把.trn文件里的拼音行完全剔除只保留第一行汉字re.sub里配的[A-Za-z0-9...]正则能够把混进文本的英文字母、数字、半角标点清掉。注意我没有在脚本里做乱序切分因为 ASRT 的官方切分有固定规则A1-A4 目录用作训练A5 用作验证A6-A8 用作测试。如果你的目录编号不是这个规律就要先按编号前缀分组再分别执行build_csv。实际项目中我看到不少坑都出在路径没abspath训练时工作目录一换就找不到 wav 文件所以这里专门转成绝对路径。2.2 MFCC 特征参数ASRT 默认用 26 维窗长 25msASRT 在 TensorFlow 2.0 版本下没有直接输入 wav而是先通过python_speech_features库计算 MFCC。这个库的性能不像 torchaudio 那么激进但胜在稳定配合 THCHS30 这种 16kHz 采样率的中文数据效果足够。特征参数如果设置不对模型恢复训练后会直接 shape mismatch。下面是我通常维护的一套参数参数名默认值说明sampling_rate16000THCHS30 原始 wav 的采样率num_mfcc26MFCC 维度太大会引入高频噪声win_len0.02525ms 窗长对应 400 个采样点win_step0.0110ms 步长对应 160 个采样点num_fft512FFT 点数小于 400 会截断窗口maxlen800按帧数对齐音频越长截断越多vocab_size4337词表大小需与模型输出 Dense 层一致MFCC 计算和特征对齐的代码在 ASRT 里通常写在DataLoader类中抽取出来大概是import numpy as np import python_speech_features as psf def compute_mfcc(wav_data, samplerate16000, maxlen800): # wav_data 是已经用 librosa 或 wave 读出的 float 数组 mfcc psf.mfcc( wav_data, sampleratesamplerate, numcep26, nfft512, winlen0.025, winstep0.01 ) # 均值归一化消除说话人音量差异 mfcc (mfcc - np.mean(mfcc, axis0)) / (np.std(mfcc, axis0) 1e-8) if len(mfcc) maxlen: mfcc mfcc[:maxlen] else: pad_width maxlen - len(mfcc) mfcc np.pad(mfcc, ((0, pad_width), (0, 0)), modeconstant) return mfcc.astype(np.float32)maxlen800是经验值对应 16kHz 音频约 8 秒。THCHS30 平均音频时长在 6 秒左右800 帧足够覆盖绝大多数句子如果句子超过 8 秒直接截断会丢尾部内容。我建议做二次开发时把maxlen调到 960 或 1000虽然显存占用略涨但能明显降低长句的 WER。这里的归一化不能省THCHS30 有些音频是不同设备录的能量差异很大不做均值方差归一化会让 LSTM 的输入分布漂移。2.3 用 tf.data 把 CSV 变成训练管道有了 CSV 和特征计算函数下一步是用 TensorFlow 2.0 的tf.data.Dataset搭建数据管道。这里的关键是不能在map里直接塞compute_mfcc因为tf.py_function会拖慢训练。常见做法是先加载全部 wav 路径和标签再用py_function计算特征import tensorflow as tf def parse_csv_line(line): parts tf.io.decode_csv(line, record_defaults[, ]) return parts[0], parts[1] def load_wav_and_label(wav_path, text): # 用 tensorflow.io 读 wav避免手动文件句柄 audio_binary tf.io.read_file(wav_path) audio, sr tf.audio.decode_wav(audio_binary, desired_channels1) audio tf.squeeze(audio, axis-1) return audio, text def preprocess(audio, text): audio tf.numpy_function( compute_mfcc, [audio], tf.float32 ) text tf.strings.lower(text) return audio, text dataset tf.data.experimental.make_csv_dataset( train.csv, batch_size1, num_epochs1, headerTrue, column_names[wav_path, transcript] ).unbatch().map(load_wav_and_label).map(preprocess).padded_batch( batch_size32, padded_shapes([800, 26], []), drop_remainderTrue )这段代码的难点在padded_batch由于每条音频算出的 MFCC 帧数可能不同必须把特征对齐到统一的[800, 26]标签则保持原样。make_csv_dataset会自动解析 CSV 头省去自己写decode_csv的麻烦但要注意它默认会 shuffle所以训练和验证要用不同的seed否则 dev 集也被打乱评估结果不稳定。训练时 batch size 选 32 在 RTX 3060 级别显卡上刚好占满显存再大可能 OOM尤其是带 LSTM 层的模型。这里的tf.numpy_function是 Python 函数和 TensorFlow 图之间的桥它没有梯度回传所以只能放在特征输入阶段绝不能放到模型内部层里。3. 恢复训练与参数继承从.model.base到base_weight.h5拿到模型文件后首先要区分三件事.model.base是什么weight.ckpt.data-*是什么base_weight.h5又是什么。ASRT 在训练到每 251 个 step 时会把 Keras 完整模型保存为speech_model251_e_0_step_625000.model.base这个文件里同时包含网络结构和训练状态。weight.ckpt.data-00000-of-00002是 TensorFlow 的 checkpoint 权重分片通常配合同目录下的.index文件使用。base_weight.h5是save_weights()的产物只有权重不包含模型结构。如果你只想做推理用.model.base最快如果你想换数据集微调用.h5更干净。3.1 ASRT 的模型骨架CNN LSTM CTCASRT 在 TensorFlow 2.0 下的主干网络不是端到端 Transformer而是保留了传统语音识别的习惯先用两层 CNN 做频谱特征抽取再接双向 LSTM 做时序建模最后用全连接层映射到词表大小损失函数用 CTC。下面是我根据 ASRT 源码结构重建的简化模型方便理解恢复训练时的 shape 关系from tensorflow.keras import layers, Model def build_asrt_model(input_shape(800, 26), vocab_size4337): inputs layers.Input(shapeinput_shape, namemfcc_input) x layers.Conv2D(32, (3, 3), paddingsame, activationrelu)(inputs) x layers.MaxPooling2D((2, 1))(x) x layers.Conv2D(64, (3, 3), paddingsame, activationrelu)(x) x layers.MaxPooling2D((2, 1))(x) # 转时序表示去掉 Pooling 后的高度维度 _, time_steps, height, channels x.shape x layers.Reshape((time_steps, height * channels))(x) x layers.Bidirectional(layers.LSTM(128, return_sequencesTrue))(x) x layers.Bidirectional(layers.LSTM(128, return_sequencesTrue))(x) x layers.Dense(vocab_size, activationsoftmax, namectc_output)(x) model Model(inputs, x) return model模型里有几个必须对齐的参数。input_shape第二维必须等于特征计算时的maxlen否则加载base_weight.h5会报shape mismatch。vocab_size4337是 THCHS30 词表大小如果训练时用的是全量汉字表这个数字不能随便改。CNN 的MaxPooling2D只在时间维和通道维上压缩所以Reshape之后的时间步长变为原来的四分之一LSTM 层的return_sequencesTrue保证每个时间步都输出一个概率分布。CTC 解码时会把重复帧和空白符折叠掉所以最终识别的文本长度会远小于输入帧数。3.2 两种恢复方式的使用场景我一般分两种场景处理这些权重文件。第一种是完整恢复直接用load_model加载.model.base适合接着原来的学习率继续训练from tensorflow.keras.models import load_model model load_model(speech_model251_e_0_step_625000.model.base, compileFalse) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-4), lossNone )这里的compileFalse是故意不加载原始损失因为 CTC 损失在保存时可能有自定义对象直接load_model会报Unknown loss function。第二种是只加载权重适合做迁移学习model build_asrt_model(input_shape(800, 26), vocab_size4337) model.load_weights(base_weight.h5, by_nameTrue)by_nameTrue可以跳过不匹配的层比如你想把模型输出层改成自定义词表只需重建网络时把vocab_size改成新值然后加载前面的卷积和 LSTM 层权重。这里有个坑保存base_weight.h5时如果包含optimizerload_weights会额外加载优化器变量导致文件体积膨胀。ASRT 的.h5通常不保留所以使用前可以打印model.weights确认。如果遇到权重层名称不一致先model.summary()对照一下再决定是改代码还是用by_name。3.3 续训时调参数checkpoint 周期和学习率衰减恢复训练时最容易忽略的是学习率。这个模型已经跑到 step 625000按 ASRT 的步长这相当于几百个 epoch 之后的状态学习率应该非常低。若还是用默认的 1e-3大概率会让原先收敛的损失直接炸掉。我通常在续训开始时把学习率降到 1e-5然后每隔 1000 步乘 0.9 做指数衰减lr_schedule tf.keras.optimizers.schedules.ExponentialDecay( initial_learning_rate1e-5, decay_steps1000, decay_rate0.9, staircaseTrue ) model.compile( optimizertf.keras.optimizers.Adam(learning_ratelr_schedule), losstf.keras.losses.CategoricalCrossentropy() ) checkpoint tf.keras.callbacks.ModelCheckpoint( resume_{epoch}_{step}.model.base, monitorval_loss, save_weights_onlyFalse, save_freq251 )save_freq251是因为 ASRT 的 checkpoint 命名里 step 数按 251 递增这样保存出来的文件能跟原有命名规则对齐。ModelCheckpoint默认保存权重而不是完整模型这里save_weights_onlyFalse确保存下来的是带结构的.model.base。如果你显存不够可以在fit之前先跑一遍model.predict做 dry run避免训练到一半 OOM 导致白跑。另一个细节是 batch size 变了CTC 输出的时序步数不变但梯度更新幅度会变所以续训时 batch size 最好和原来保持一致否则学习率衰减的曲线得重新调。4. 评估与识别用 WER / CER 量化模型精度模型训练完或恢复完不能只盯着 loss 看还要在dev.csv、test.csv上算字错率CER。ASRT 的解码默认是贪心路径直接取每个时间步概率最大的字符然后合并重复和空白。这种解码方式速度快但精度一般尤其对中文长句容易丢字。评估时我把解码和编辑距离计算分开写这样能快速定位是模型问题还是解码策略问题。4.1 在 test.csv 上批量计算 CER字错率的计算基于最小编辑距离这里用editdistance库最方便import editdistance import tensorflow as tf import numpy as np def greedy_decode(pred_probs): # pred_probs shape: (batch, time, vocab) pred_ids np.argmax(pred_probs, axis-1) decoded [] for seq in pred_ids: prev -1 text [] for idx in seq: if idx ! prev and idx ! 0: # 0 为 blank text.append(idx) prev idx decoded.append(text) return decoded def evaluate_cer(model, dataset): total_cer 0.0 count 0 for batch_x, batch_y in dataset: preds model.predict(batch_x, verbose0) pred_ids greedy_decode(preds) for pred, label in zip(pred_ids, batch_y): # label 是原始汉字字符串需要转成索引列表 pred_str index_to_text(pred) cer editdistance.eval(pred_str, label) / max(len(label), 1) total_cer cer count 1 return total_cer / count这段代码有几个需要按实际项目改的地方。idx ! 0假设词表里 0 号是 CTC blank如果 ASRT 把 blank 放在词汇表末尾就把条件改成idx ! vocab_size - 1。index_to_text从 CSV 的词表文件里映射索引到汉字ASRT 的源码里有一个load_vocab()函数负责这件事。在 test.csv 上算 CER 时batch 里的标签需要保持原始字符串不能像训练那样做 one-hot否则编辑距离无法计算。如果batch_y是数值索引序列需要先查表转回汉字。4.2 束搜索解码与贪心解码的取舍贪心解码只考虑当前时刻最优路径忽略上下文约束所以经常出现“前后音相似但语义错误”的问题。束搜索beam search保留多条候选路径用条件概率累积选择最优中文识别时beam_width64通常能比贪心降 2-6% 的 CER。TensorFlow 2.0 里可以直接用tf.nn.ctc_beam_search_decoderdef beam_search_decode(pred_probs, seq_lens, beam_width64): # pred_probs shape: (time, batch, vocab) 是 CTC 需要的排列 probs tf.transpose(pred_probs, [1, 0, 2]) decoded, _ tf.nn.ctc_beam_search_decoder( inputsprobs, sequence_lengthseq_lens, beam_widthbeam_width, top_paths1 ) # decoded 是稀疏张量转为稠密序列 dense tf.sparse.to_dense(decoded[0]) return dense.numpy()要注意sequence_length必须等于模型输出时间步的真实长度也就是输入帧数经过池化之后的结果不能直接填 800。如果你的输入统一 pad 到 800那么经过两次MaxPooling2D((2,1))后时间步是 200如果输入不是定长就要在model.predict时记录每个样本的有效帧数。束搜索慢在 CPU 解码阶段GPU 只在预测时加速。如果测试集很大建议先用贪心解码跑一遍再挑 CER 异常的样本做束搜索对比。实际部署到实时识别场景时beam_width不建议超过 128否则延迟会从毫秒级变成百毫秒级。4.3 长音频识别滑窗 拼接THCHS30 都是短句子但实际使用中会遇到几十秒的录音。直接把长音频塞进模型会触发maxlen截断尾部内容丢失。我的做法是先做 VAD 检测有效语音段再按 8 秒窗口滑动每个窗口保留 1 秒重叠避免切在词中间。切分后把每段识别文本按时间戳拼接代码框架如下import librosa def split_long_audio(wav_path, window_sec8.0, overlap_sec1.0): audio, sr librosa.load(wav_path, sr16000) win_len int(sr * window_sec) hop int(sr * (window_sec - overlap_sec)) segments [] start 0 while start len(audio): end min(start win_len, len(audio)) segments.append((start / sr, end / sr, audio[start:end])) if end len(audio): break start hop return segments这里overlap_sec太小时容易切碎声母太大时重复识别率高。如果你有标点信息或停顿信息可以用静音检测来自适应切分点。长音频识别后相邻窗口的拼接需要按时间戳归并不要简单把文本首尾相连否则一个词被切成两半会多出无意义的字。这个技巧在 ASRT 的部署社区里很常见核心是保住基于深度学习的声学模型不因输入长度失稳。5. 让模型落地的几个具体技巧最后一章不谈大框架讲三个我用这套权重文件踩过坑之后沉淀下来的技巧全部可以在你手头的资源上验证。5.1 恢复训练前先校验词表一致性由于压缩包里的.model.base已经训练到 62 万步词表顺序和默认的 THCHS30 词表是一致的。但如果你用base_weight.h5去微调自己的数据一定要检查词表映射。我写过一个快速校验脚本随机挑 20 个预测结果把索引转成汉字打印出来看是否出现“是”和“不”错位的情况。出现这种错位说明新词表的label_to_index顺序跟原模型训练时不一致需要在加载权重前对 Dense 层的权重做重排。这一步比调学习率影响还要大词表错了模型永远不会收敛。5.2 增量训练把新数据写进 data.csvASRT 支持把新标注数据追加到data.csv再重新切分train/dev/test。增量训练时不要从第一个 step 开始跑而是加载现有的.model.base把新数据按 1:3 的比例混入原始训练集。我一般会控制新数据占比避免模型遗忘旧数据。如果新数据里包含原本不在词表里的汉字需要扩充 vocab_size此时 Dense 输出层权重都应该重新初始化而前面的卷积和 LSTM 层保留。加载方式用load_weights(..., by_nameTrue)但要注意新 Dense 层的名称不能和旧层重复否则加载时会报错。5.3 导出为 SavedModel 并固定 batch 维度部署时如果直接保存.model.baseKeras 会保留训练相关的操作不适合 TensorFlow Serving。我通常导出 SavedModel并固定 batch 维度为 1减少线上显存占用model load_model(speech_model251_e_0_step_625000.model.base, compileFalse) # 用 batch1 的输入构建新签名 tf.function(input_signature[ tf.TensorSpec([None, 800, 26], tf.float32, namemfcc) ]) def serve_fn(inputs): return {outputs: model(inputs)} tf.saved_model.save(model, exported/1, signatures{serving_default: serve_fn})导出后可以用saved_model_cli或在 Python 里加载验证输入 shape 不要写死[1, 800, 26]而是[None, 800, 26]这样同一份模型还能兼容离线批量评测。如果你要用 TFLite需要把 LSTM 层转成tf.lite支持的形式THCHS30 模型里的双向 LSTM 在 TFLite 兼容性上偶尔会出问题建议先只输出概率矩阵解码放到服务端完成。验证导出的方式很简单用test.csv里第一条音频计算出 MFCC喂给 SavedModel跟原始模型的预测结果对比误差应该在 1e-5 以内。若误差大多半是输入特征预处理不一致回到第 2 章检查归一化参数。本文还有配套的精品资源点击获取