
簡介基于深度學習CNN與LSTM融合架構的高效分類系統完整源碼與說明聚焦于心電ECG心律失常的精準識別適合計算機、數學、電子信息等專業學生用于課程設計、期末大作業或畢業設計也便于入門者結合代碼開展實戰演練。壓縮包共3個文件包含Python源碼、README說明與詳細介紹文檔整體僅630KB源碼可直接運行文檔對模型搭建與分類流程做了必要講解方便快速理解項目結構與實踐思路。目前已有119人學習下載。讀者可借助該案例掌握CNN特征提取與LSTM時序建模的聯合應用包括數據預處理、模型訓練與評估等關鍵環節為心電信號分類任務提供可復用的參考實現和改進入手點。1. 從一條心電信號到一份診斷結論中間差的是一次精準的特征映射心電ECG信號本質上是毫伏級的時序電位變化一個正常心跳周期里P波、QRS波群、T波各有形態而心律失常恰恰就藏在這些形態的細微偏移中。傳統規則引擎比如Pan-Tompkins做QRS檢測后再按閾值判斷在基線漂移、噪聲干擾、個體差異面前非常脆弱臨床數據里信噪比稍差誤報率就失控。深度學習解決的是“特征不用人肉定義”的問題CNN擅長在局部窗口里摳形態特征LSTM擅長捕捉心拍之間的時序依賴兩者串起來正好對應心電圖判讀的兩層邏輯。這個新版源碼能幫到的場景也很明確——拿到MIT-BIH這類標注數據后不用從零搭實驗環境直接在預處理、模型結構、訓練策略三層上做替換和調優適合正在做生物信號分類課題、或者想把手寫規則升級成端到端方案的研究生和算法工程師。2. 模型輸入前的ECG信號預處理質量決定分類上限2.1 為什么原始心電數據不能直接喂給CNNLSTM原始ECG信號采樣率通常是360Hz或500Hz時長從幾十秒到24小時不等直接丟進網絡有兩個問題一是幅值尺度不統一不同設備的增益差異會讓同一類心拍的數值分布完全不同二是噪聲成分復雜工頻干擾50/60Hz、肌電干擾、基線漂移都會在時域上扭曲波形。必須先用帶通濾波器比如0.5Hz到45Hz的Butterworth把噪聲壓下去。這里有一個多數教程不會強調的細節濾波順序要先工頻陷波再做帶通順序反了會引入振鈴效應。預處理后的信號還需要做切片segmentation。分類的對象不是整段長信號而是以R峰為中心截取的心拍窗口。通常做法是前后各取0.4秒到0.5秒360Hz采樣率下對應288到360個采樣點。窗口太小會截斷T波窗口太大會讓相鄰心拍混入當前窗口干擾模型的注意力。我一般用0.83秒窗口300個采樣點 360Hz這是個在MIT-BIH上效果穩定的經驗值。2.2 小波去噪與數據標準化的具體實現在講模型之前先把預處理跑通這塊直接決定你能不能復現出論文里的準確率。下面這段代碼是基于PyWavelets實現的ECG去噪和切片流程適合作為源碼里的preprocess.py去理解。import numpy as np import pywt def denoise_ecg(signal, waveletdb4, level4): # 小波分解把信號拆成不同頻帶的分量 coeffs pywt.wavedec(signal, wavelet, levellevel) # 估計噪聲標準差用高頻分量的中位數絕對偏差計算 sigma np.median(np.abs(coeffs[-1])) / 0.6745 # 軟閾值去噪只處理細節系數保留近似系數低頻主體 coeffs_thresh [coeffs[0]] [ pywt.threshold(c, sigma * np.sqrt(2 * np.log(len(signal))), modesoft) for c in coeffs[1:] ] # 重構回時域信號 return pywt.waverec(coeffs_thresh, wavelet) def segment_ecg(ecg, r_peaks, fs360, before0.3, after0.5): # 以R峰為中心截取心拍窗口返回歸一化后的樣本和標簽索引 samples [] for r in r_peaks: start int(r - before * fs) end int(r after * fs) if start 0 or end len(ecg): continue beat ecg[start:end] # 每個窗口獨立做z-score歸一化消除個體基線差異 beat (beat - beat.mean()) / (beat.std() 1e-8) samples.append(beat) return np.array(samples)pywt.threshold里的閾值公式sigma * sqrt(2 * log(N))來自Donoho的經典小波收縮理論它解決的是“哪些小波系數是噪聲、哪些是真實波形”的自動判別問題。z-score歸一化放到切片之后就是為了避免全段標準化把局部幅值差異抹平——某些早搏PVC的形態特征恰恰體現在局部幅值異常。2.3 標簽編碼與數據集切分的關鍵點MIT-BIH的標注體系是AAMI標準共5大類N類正常/束支阻滯、S類室上性異位、V類室性異位、F類融合搏動、Q類未知/起搏。源碼里的標簽處理必須做一次映射把MIT-BIH原始標注符號轉成這五類整數編碼。這里有個臨床背景要清楚S類和V類的區分直接對應用藥方向混淆這兩個類別的模型在臨床上沒有意義。數據切分時絕對不能用隨機打亂同一個病人的心拍會同時出現在訓練集和驗證集中造成嚴重的數據泄露。正確做法是按病人編號分組切分——Common MIT-BIH推薦用101、106、108、109、112、114、115、116、118、119、122、124、201、203、205、207、208、209、215、220、223、228作為訓練組其余作為測試組。這個細節幾乎決定了模型泛化結果的真實性。3. 構建CNNLSTM混合模型形態特征與時序上下文的分工協作3.1 網絡設計的核心分工邏輯這個標題里的核心詞落到模型設計上就是“先抽象空間時域窗口特征再建模時間依賴”。一維卷積Conv1d做的就是沿時間軸滑動、提取局部形態特征比如QRS波的尖銳程度、ST段的抬高幅度。但單靠CNN不行因為它對特征的感知有“感受野”限制——就算堆很多層本質還是在做局部匹配而且對特征出現的先后順序不敏感。LSTM接在CNN后面就是干這個的把CNN抽取到的高層特征當作一個序列去讀捕捉“先一個正常心拍、接著一個早搏、然后一段代償間歇”這類時間模式。準確來說這是個“CNN特征提取器 LSTM序列建模器”的級聯結構。常見做法里CNN用兩層Conv1d逐漸把300個采樣點壓到更短的序列長度然后在時間維度上保留給LSTMLSTM用兩層雙向結構每層隱藏單元128雙向的好處是能同時看到當前心拍前后的上下文。最后接全局池化或取最后一個時間步的輸出過全連接層后用Softmax出5類概率。3.2 基于PyTorch的模型主體代碼import torch import torch.nn as nn class ECG_CNN_LSTM(nn.Module): def __init__(self, n_classes5, input_channels1): super().__init__() # 第一層卷積input 300個點 - 輸出150個點stride2 self.conv1 nn.Sequential( nn.Conv1d(input_channels, 64, kernel_size7, stride2, padding3), nn.BatchNorm1d(64), nn.ReLU() ) # 第二層卷積局部感受野擴大通道數增加 self.conv2 nn.Sequential( nn.Conv1d(64, 128, kernel_size5, stride2, padding2), nn.BatchNorm1d(128), nn.ReLU() ) # 雙向LSTM把CNN輸出的特征序列按時間步建模 self.lstm nn.LSTM( input_size128, hidden_size128, num_layers2, bidirectionalTrue, dropout0.3 ) # 全連接分類頭接收雙向LSTM拼接后的輸出256維 self.classifier nn.Sequential( nn.Linear(256, 64), nn.Dropout(0.5), nn.ReLU(), nn.Linear(64, n_classes) ) def forward(self, x): # x shape: (batch, seq_len) - (batch, channels, seq_len) x x.unsqueeze(1) x self.conv1(x) x self.conv2(x) # 輸出形狀(batch, 128, 75) # 轉成LSTM需要的格式(seq_len, batch, features) x x.permute(2, 0, 1) out, _ self.lstm(x) # (seq_len, batch, 256) # 取最后一個時間步的輸出 —— 等價于只保留最終編碼信息 out out[-1] y self.classifier(out) return yConv1d的stride2在這里有兩層意思一是直接減半序列長度、降低LSTM的時間步數節省計算量二是讓卷積的平移不變性在一定程度上覆蓋“心拍的輕微時間偏移”。permute(2, 0, 1)這一步是新手最容易犯錯的PyTorch的LSTM默認首維是時間步長要是不調整維度順序就會報維度錯誤或者靜默地學到錯誤映射。取out[-1]本質上是拿最后一個時刻的隱狀態代表整個序列的摘要你也可以換成torch.mean(out, dim0)做全局平均池化在短序列任務里后者表現往往更穩。3.3 參數量與計算量的權衡模塊輸出形狀關鍵超參數參數量約Conv1(1D)(64, 150)kernel7, stride20.5KConv2(1D)(128, 75)kernel5, stride241KBi-LSTM(75, 256)hidden128, layers2528KClassifier(5)256→64→516.6K合計約 590K590萬參數對這個任務來說是合理的。ECG信號結構相對簡單不需要像圖像分類那樣動輒上千萬參數但LSTM的循環結構決定了計算圖是按時間步展開的訓練時反向傳播會跨越75個時間步消耗的顯存比同參數量CNN高不少。GPU顯存低于4GB的話建議把LSTM的hidden_size降到64或者把雙向改成單向付出的代價是S類和V類之間的區分度會下降約2到3個百分點。3.4 模型結構替代方案對比純CNN如ResNet1D只用卷積堆疊感受野推理速度最快但面對復雜的室早二聯律這類明顯依賴上下文的心律失常效果不如混合結構。純LSTM/GRU時序建模能力強但對上面說的形態細節如ST段抬高水平感知弱因為LSTM的每個時間步看到的是原始采樣點不是抽象特征。CNN Attention用自注意力替代LSTM的循環路徑訓練并行度高但需要更多數據小數據集下比如單病人樣本1000容易過擬合。標題里鎖定了LSTM這個混合方案就是當前最穩的基線。4. 訓練策略與實驗驗證類別不平衡和過擬合的針對性解法4.1 數據層面的不平衡處理ECG分類面對的不是普通不平衡問題——MIT-BIH里N類心拍占比接近87%而F類融合搏動通常不到3%。我見過不少人在這個數據集上直接把CrossEntropyLoss跑到底驗證集里F類的召回率是0而整體準確率還很好看因為負樣本太多。關鍵調參動作是用weighted sampler或者直接在損失函數里給每個類別加權。比直接調class_weight更穩的組合是先做少數類過采樣離線重復F類和S類樣本再用加權損失函數微調。注意過采樣不能破壞時間上下文——LSTM部分學的是心拍間關系如果你在序列維度上簡單復制心拍模型會把“復制粘貼”這個模式學進去結果訓練集上表現異常好、測試集上立刻崩潰。做法上要保證過采樣的是樣本心拍窗口而不是改變樣本內部的時間順序。4.2 訓練主循環中的三個關鍵參數下面是訓練腳本里的核心片段可以作為源碼中train.py的參照from sklearn.metrics import classification_report from torch.utils.data import WeightedRandomSampler # 用每個類別的樣本數反比作為采樣權重 class_counts torch.bincount(train_labels) weights 1.0 / class_counts.float() sample_weights weights[train_labels] sampler WeightedRandomSampler( sample_weights, num_sampleslen(sample_weights), replacementTrue ) # 學習率調度Plateau方式 —— 驗證集指標停滯就降一半 scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience5 ) criterion nn.CrossEntropyLoss() best_f1 0.0 for epoch in range(200): model.train() train_loss train_one_epoch(model, train_loader, criterion, optimizer) model.eval() val_f1 evaluate_f1(model, val_loader) # 用加權F1而不是acc做早停指標 scheduler.step(val_f1) if val_f1 best_f1: best_f1 val_f1 torch.save(model.state_dict(), best_ecg_model.pt)WeightedRandomSampler的replacementTrue意味著同一批樣本可能被重復取出這是有放回隨機采樣的標準用法目的是讓Dataloader每次都盡可能多的看到少數類樣本。ReduceLROnPlateau里監控的指標不是loss而是F1這個選擇背后的邏輯是loss下降往往只是頭部類別擬合得更好對少數類的改善貢獻可能很小。表訓練超參數建議取值超參數推薦值調整說明batch_size128太小LSTM訓練不穩太大少數類被稀釋max_epochs120-200配early_stop一般在60-80輪收斂optimizerAdamWweight_decay設1e-4比Adam泛化好learning_rate1e-3預熱3輪后線性衰減或用Plateau自動降dropout0.3-0.5兩個位置都設LSTM層內和全連接前label_smoothing0.1減少過擬合對硬標簽噪聲有耐受label_smoothing0.1的效果等價于把正確類的logit目標從1改成0.9其余0.1攤到其他類上。這對ECG任務特別有意義——因為標注存在天然噪聲相鄰竇性心拍的形態高度相似模型在硬標簽下容易產生過度自信的錯誤判讀。4.3 模型輸出與評估指標的無偏見驗證評估階段用的evaluate_f1函數不能只看平均F1要看每個類別的recall和precision尤其是V類和S類的separate報告。在ECG分類的論文輸出里幾乎都會提到一個指標叫“整體準確率OA”和“平均準確率AA”OA容易被大類主導真正反映模型能力的是AA或加權F1。判斷模型是否過擬合最后一步是查看模型在分類層前的特征嵌入——用t-SNE降維可視化正常心拍和異常心拍應該呈可分離的團簇。如果兩類完全重疊說明LSTM根本沒有學到有效的時間特征回去調網絡深度的意義不大反而應該增加CNN提取特征時的通道數。5. 推理階段的類別映射與臨床場景適配——讓模型輸出變成可用結論模型訓練完成后要解決的最后一個問題模型輸出的5類概率分布如何轉成臨床可操作的判斷。這里有一個常被忽略的技術細節源碼里的這個模型很可能只訓練了單導聯數據而臨床上12導聯ECG信息的冗余性很高若直接在不同采樣率如250Hz的設備上部署模型的準確率會下降至少8到10個百分點。原因在于模型的卷積核大小是按360Hz的采樣率設計的用250Hz數據推理時一個kernel_size7的卷積窗口實際覆蓋的時間跨度變長了導致形態特征錯位。正確的做法是在推理管線的入口處加一個重采樣步驟同步到模型訓練時的采樣率而不是重新訓練模型——重采樣可以是簡單的線性插值或者更光滑的sinc插值。推理輸出的后處理也要并行做平移不變校準因為切片是以R峰對齊的如果實際部署時R峰檢測產生了一點偏移比如5到10個采樣點模型對這些偏移是有容忍度的但超過15個采樣點就會被當成另一個類的形態。實現上可以在R峰后多截幾個offset的窗口取概率平均值這是一種成本極低但收益明確的增強方法。最后把這個流程封裝成函數時代碼結構可以這樣組織def predict_ecg_beat(model, beat_signal, fs): # 重采樣到模型使用的采樣率 if fs ! 360: beat_signal resample_to(beat_signal, fs, 360) # 與訓練階段一致的歸一化方式 beat_signal z_norm(beat_signal) with torch.no_grad(): logits model(torch.tensor(beat_signal).float().unsqueeze(0)) probs torch.softmax(logits, dim-1) # 返回最大概率類別與對應置信度置信度低于0.6的輸出標記為“待復核” conf, cls probs.max(dim-1) cls cls.item() if conf.item() 0.6 else -1 # -1表示需人工復核 return AAMI_CLASS_NAMES[cls] if cls ! -1 else Uncertain現場部署時把輸出過一遍低置信度攔截要比盲目信任模型的最高概率更符合臨床習慣。若模型對某條心拍輸出的置信度普遍處于0.4到0.6之間條心拍大概率就是融合搏動F類或者在形態上介于兩個類之間的典型邊界樣例這類樣本的標注連專家都要靠更多上下文才能判定機器給一口咬死反而不負責。做臨床輔助工具的邏輯從來不是替代醫生而是用低置信度標記幫醫生聚焦在需要人工復核的片段上。本文還有配套的精品資源點擊獲取