
简介面向中医脉象数字化识别的 Python 源码项目基于深度学习对脉象信号进行采集、去噪、特征提取与自动分类适用于医疗健康领域的研究者、算法工程师及对智能诊断感兴趣的 Python 开发者。资源共 61 个文件核心为 47 个 .py 源码文件覆盖数据预处理、特征提取、CNN/RNN 模型训练与推理等环节另含 8 个 csv 数据样本、1 个 h5 模型权重文件、3 个 md 文档及依赖清单压缩包仅 1.24MB整体轻量且结构完整。工程包含后端服务、API 测试、中间件等模块并附依赖清单与项目说明文档便于快速部署和二次开发。目前已有 160 人学习下载。通过学习本资源可掌握脉象信号处理与深度学习识别流程理解从数据采集到结果输出的完整链路代码中提供了示例数据、训练权重及依赖环境既可直接运行验证也可基于现有代码调整模型结构、替换数据以适配自有场景适合作为课程设计、毕业设计或医疗 AI 研究的参考基线。1. Python脉象识别系统不玄学先把“寸关尺”变成可计算信号把三根手指搭在桡动脉上中医师感受到的浮沉迟数、弦滑虚实本质上是一段随时间变化的压力波。Python脉象识别系统做的事情就是用传感器把这段压力波采下来再用信号处理和机器学习把它分类成可理解的脉象标签。说句反直觉的话这个系统最难的不是训练模型而是让波形干净到模型敢信很多人把精力花在调网络结构上最后发现80%的准确率瓶颈居然在数据标注和周期切分。这套方案适合正在做中医数字化、可穿戴脉诊或健康管理项目的人也适合只想把“脉象识别”作为毕设主题快速落地一个可演示原型的工程师。下面按我实际搭过的一条完整管线展开讲。2. 从“按下去”到“读出来”脉象数据采集与标注方案2.1 传感器选型压阻、PVDF与超声多普勒怎么选脉象识别系统的源头是传感器选型直接决定后面所有算法能做多“细”。常见做法是三类传感器压阻式、PVDF压电薄膜和超声多普勒。压阻式传感器靠应变片或半导体压阻感知静态压力对接触压力的大小变化非常敏感优点是便宜、接口简单、温漂可控缺点是动态响应一般佩戴时需要靠腕带或气囊给一个稳定的初始压力。PVDF薄膜是压电材料只对压力变化有响应测脉搏这种周期性脉动很灵敏但静态分量测不了意味着“浮脉沉脉”这种由静态压力大小体现的信息它天然缺失。超声多普勒能测血流速度和血管壁运动信息量最大但成本高、系统复杂做科研可以做量产原型不划算。我的选择建议做演示和课程设计用压阻式便宜且能同时拿到静态分量做可穿戴原型用PVDF加一个独立的静态压力传感器做补偿。无论选哪种稳定贴合桡动脉是硬前提传感器位置偏移几毫米波形形态就完全不一样这是后面所有“翻车”的第一来源。2.2 采样率与量程给采集卡定三个底线参数脉象信号的能量主要集中在0.5Hz到20Hz之间但这不代表你可以按40Hz去采样。主波、潮波和重搏波之间的间隔非常窄尤其在心率80次/分钟以上时潮波和重搏波可能相隔不到80毫秒。参数推荐底线理由采样率250Hz最好500Hz低于100Hz时潮波和重搏波容易被混叠成一个平台ADC位数12bit以上脉搏波幅度动态范围大8bit会把重搏波直接量化掉量程按加压值1.5~2倍留余量腕带加压瞬间会有大的静态分量容易削顶这三个参数里最容易被忽略的是量程。很多采集板默认0~3.3V加压后静态压力已经占了80%量程脉搏波动只剩很小的幅度导致重搏波被量化噪声淹没。实操上我会先做一次加压斜坡测试观察波形不削顶、底噪不明显的最小增益。2.3 标注协议把浮沉迟数转成可训练的标签数据采集只是拿到一段连续波形真正决定模型上限的是标注。脉象标注不是一个人说了算常见做法是双人独立标注再加仲裁。标注前要先定义清楚标签边界比如“浮脉”指轻取即得、重按稍减那么在传感器信号上可以约定为静态压力分量小且幅值随压力衰减快“沉脉”则相反。操作步骤让两位有经验的中医师独立看同一批波形各自给出标签标签集控制在5~8类以内比如浮、沉、迟、数、弦、滑、平脉。计算两人标注的Cohen‘s Kappa低于0.6就说明标注规则没对齐需要重看边界案例。对不一致样本由第三人仲裁仲裁后仍存疑的样本直接丢弃不要硬喂给模型。把标注结果按“受试者ID 采集时间 标签”整理成CSV后面训练时按受试者分组做验证。这一步看着最像管理工作却是整个系统准确率的真正“地基”。很多源码项目看起来算法精巧复现时准确率上不去大概率是标注协议缺失导致标签本身是脏的。3. 预处理与周期分割把一段乱糟糟的腕部信号切成单拍脉搏3.1 去基线漂移与电源干扰的组合拳原始信号里除了脉搏波还有呼吸引起的基线缓慢起伏、人体活动和电源带来的50Hz周期干扰。常见做法是先用高通滤波器去掉0.5Hz以下漂移再用陷波器去掉50Hz能量最后低通到30Hz保留脉搏主频段。import numpy as np from scipy.signal import butter, filtfilt def preprocess_pulse(signal, fs250.0): # 高通0.5Hz以上去除呼吸和缓慢基线漂移 b_high, a_high butter(2, 0.5 / (fs / 2), btypehigh) signal filtfilt(b_high, a_high, signal) # 陷波50Hz电源干扰若采集环境干净可以跳过 b_band, a_band butter(2, [49.0 / (fs / 2), 51.0 / (fs / 2)], btypebandstop) signal filtfilt(b_band, a_band, signal) # 低通30Hz以上基本是肌电噪声脉搏能量集中在20Hz内 b_low, a_low butter(4, 30.0 / (fs / 2), btypelow) signal filtfilt(b_low, a_low, signal) return signal逻辑说明高通滤波器的截止频率取0.5Hz因为正常呼吸频率在0.2~0.4Hz必须压到最低陷波器用49~51Hz的窄带只干掉电源干扰而不伤附近的脉搏谐波低通取30Hz是给高频留余量避免把潮波上升沿削圆。参数上要注意二阶段filtfilt是零相位滤波不会引入波形时移这正是后续要比较主波和重搏波时间间隔的前提。3.2 用scipy做峰值检测与脉搏周期切分预处理之后要把连续信号切成一个一个单拍脉搏。常见做法是找主波峰作为锚点再以“峰前0.2秒 峰后0.4秒”为窗口切分。峰值检测直接用scipy的find_peaks但只靠固定高度阈值会翻车因为不同受试者幅值差异很大。from scipy.signal import find_peaks def split_cycles(signal, fs250.0, min_height_ratio0.35): # 自适应阈值以信号中位幅度的0.35倍作为最低峰高 height np.median(np.abs(signal)) * min_height_ratio # distance设为0.4秒对应的采样点数防止把潮波错当主波 distance int(0.4 * fs) peaks, _ find_peaks(signal, heightheight, distancedistance) pre int(0.2 * fs) # 主波前0.2秒包含上升支起点 post int(0.4 * fs) # 主波后0.4秒覆盖到重搏波结束 cycles [] for p in peaks: if p - pre 0 and p post len(signal): cycles.append(signal[p - pre:p post]) return cycles, peaks逻辑说明find_peaks的distance参数是关键取0.4秒对应的采样点意味着两次主波之间至少间隔0.4秒对应心率上限150次/分钟这样能把潮波通常在主波后0.15~0.3秒从候选峰里排除掉。高度阈值设成中位幅度的0.35倍能适应不同受试者。切出来每拍长度固定为0.6秒窗口但实际心率不同会导致周期时长不一致所以下一节要处理时间轴归一化。3.3 归一化策略幅值归一化与时间归一化的取舍波形切完之后面临两个差异不同人、不同压力下幅值不同心率不同导致单个周期时长不同。常见做法是幅值归一化到0到1时间轴用等间距插值统一到256个点但这里有个隐蔽的坑如果连主波到潮波的相对时间差也一起“标准化”掉脉象的重要判别信息就丢了。from scipy.interpolate import interp1d def normalize_cycle(cycle, target_len256): x_old np.linspace(0, 1, len(cycle)) f interp1d(x_old, cycle, kindcubic) x_new np.linspace(0, 1, target_len) return f(x_new)逻辑说明这里做的是“相对时间归一化”只把时间轴映射到0~1区间但保留了上升支、潮波、重搏波在整周期里的相对位置。若要进一步突出心率信息就要单独记录每个周期的原始时长作为额外特征喂给模型而不是混在归一化后的波形里。参数target_len取256是因为后续做1D-CNN时输入长度统一为256特征维度适中计算量可控。4. 特征工程与分类模型从时域到频域再把准确率做上去4.1 时域特征主波、潮波、重搏波的定位与比值切出单拍后第一个能直接用的特征是波形形态。典型脉搏波有主波最高的波峰、潮波主波后第一个小峰、降中峡主波后的最低谷、重搏波降中峡后再抬升的波峰。这些点之间的幅度比值和时间间隔直接对应中医脉象的部分语义。from scipy.signal import argrelextrema import numpy as np def extract_time_features(cycle, fs250.0): max_idx np.argmax(cycle) h1 cycle[max_idx] # 在主波之后找局部极值先找降中峡再找重搏波 search_zone cycle[max_idx:] minima argrelextrema(search_zone, np.less, orderint(0.02 * fs))[0] max_idx maxima argrelextrema(search_zone, np.greater, orderint(0.02 * fs))[0] max_idx diacrotic_notch minima[0] if len(minima) else max_idx int(0.25 * fs) dicrotic_wave maxima[0] if len(maxima) else diacrotic_notch h3 cycle[diacrotic_notch] h4 cycle[dicrotic_wave] t1 max_idx / fs t3 (diacrotic_notch - max_idx) / fs t4 (dicrotic_wave - max_idx) / fs period len(cycle) / fs return { h1: h1, h3_over_h1: h3 / h1, h4_over_h1: h4 / h1, t1_over_period: t1 / period, t3: t3, t4: t4, }逻辑说明argrelextrema的order参数很关键设为20毫秒对应的采样点数可以避免把高频毛刺误判成潮波或重搏波。h3/h1和h4/h1是幅度比值特征对绝对压力变化不敏感能有效抵消加压大小的影响t3代表主波到降中峡的时间间隔在弦脉里通常缩短。这些特征计算简单、可解释性强是后面和中医规则互相印证的落脚点。4.2 频域特征与熵特征补上时域看不到的信息时域特征只能描述波形轮廓但部分脉象之间的差异体现在频率分布上比如滑脉的主波上升沿陡峭反映为高频能量占比更高。常见做法是取周期信号的功率谱计算几个频带能量比和谱熵。def extract_freq_features(cycle, fs250.0): n len(cycle) win np.hanning(n) spectrum np.fft.rfft(cycle * win) power np.abs(spectrum) ** 2 freqs np.fft.rfftfreq(n, d1.0 / fs) bands [(0.5, 4.0), (4.0, 8.0), (8.0, 12.0)] total power.sum() 1e-12 features {} for low, high in bands: mask (freqs low) (freqs high) features[fenergy_{low}_{high}] power[mask].sum() / total return features逻辑说明加汉宁窗是为了抑制频谱泄漏因为切出来的周期两端不一定是零值。三个频带分别是0.5~4Hz对应心率和主波基频、4~8Hz潮波与重搏波造成的谐波、8~12Hz上升沿陡峭程度相关。能量比做了归一化避免了绝对幅值干扰。谱熵可以额外算它反映信号是集中在基频还是分散在高次谐波弦脉通常谱熵偏低。4.3 模型选型随机森林、SVM与1D-CNN在脉象上的表现在样本量少几百到几千拍的常见情况下我的经验是随机森林比深度学习更容易拿到稳定结果因为特征维度小、标注噪声大树的集成对异常值更鲁棒。模型适用数据量优势劣势随机森林500~5000拍抗噪强、特征重要性可直接用于解释无法利用原始波形时序SVMRBF500~5000拍小样本分类边界稳对特征缩放敏感1D-CNN10000拍以上自动学形态特征、准确率上限更高需要大量标注数据容易过拟合from sklearn.ensemble import RandomForestClassifier from sklearn.svm import SVC def build_classifiers(): models { rf: RandomForestClassifier(n_estimators1000, max_depth8, class_weightbalanced, random_state42), svm: SVC(C10, kernelrbf, gammascale, class_weightbalanced) } return models逻辑说明随机森林的n_estimators提到1000是为了在类别不均衡时让少数类也有足够投票max_depth控制在8左右防止对标注噪声过拟合。SVM的C调成10在脉象这种特征维度20左右的小数据集上边界更紧gamma用’scale‘相比固定值更省心因为它会根据特征方差自动缩放。class_weight’balanced‘解决浮脉、沉脉这类样本不均衡问题。4.4 训练验证的固定流程别让数据泄漏骗了你脉象数据最大的泄漏源是“同一个人的多拍同时出现在训练集和测试集”。同一受试者的相邻两拍高度相似会造成准确率虚高。常见做法是必须按受试者ID分组做GroupKFold。from sklearn.model_selection import GroupKFold from sklearn.metrics import f1_score def train_and_evaluate(X, y, groups, models): gkf GroupKFold(n_splits5) for name, model in models.items(): scores [] for train_idx, test_idx in gkf.split(X, y, groups): model.fit(X[train_idx], y[train_idx]) pred model.predict(X[test_idx]) scores.append(f1_score(y[test_idx], pred, averageweighted)) print(f{name}: weighted F1 {np.mean(scores):.3f} ± {np.std(scores):.3f})逻辑说明GroupKFold的groups参数传受试者ID数组确保同一个人的所有样本只出现在同一折里。这里要特别留意幅度归一化时如果先用全量数据的统计值再切分也算泄漏。正确做法是在训练折内单独计算中位数和标准差测试折只做变换。每折输出weighted F1而不是accuracy因为脉象类别不平衡accuracy会被多数类带偏。5. 脉象识别常见坑与排查数据、标注、模型三层Debug5.1 采样率不够导致潮波和重搏波“融合”现象用100Hz采集的数据训练出来的模型浮脉和滑脉的区分度很差特征工程里t3总是零附近。原因心率75次/分钟时主波到潮波间隔仅约0.2秒100Hz意味着只有20个采样点如果潮波和重搏波之间只有0.1秒那么10个采样点根本不足以让argrelextrema的order参数区分它们。解决重新采集时把采样率提到250Hz以上如果数据已经录了先对信号做三次样条插值到原来的4倍点数再计算时域特征虽然不能凭空造出高频信息但能让峰值定位稳定不少。5.2 两个标注者之间Kappa只有0.3现象训练集标注是A做的测试集的标签是B做的F1直接掉到0.5以下模型学习到的是一套不稳定规则。原因脉象本身有主观性加上双方对“浮脉”的边界理解不同比如A认为重按时幅值衰减30%算浮脉B认为衰减50%才算。解决这个没有后悔药只能返工标注。先让双方各自标注同一批50条样本逐条比对差异并写下修订规则再重新标注全部数据仲裁后仍不一致的删除。如果时间紧可以只保留双人一致的样本做训练宁可数据量少一半也不能要脏标签。5.3 幅值偏移被识别成“浮脉”的假象现象模型报告浮脉识别准确率很高但把不同压力下采集的同一受试者数据放进去结果随压力大小剧烈波动。原因浮脉和沉脉的语义里虽然有“轻取重取”的含义但传感器原始幅值同时受接触压力影响压力固定得不准时幅值特征h1远大于真实脉象差异。解决特征是h3/h1这类比值而不是绝对幅值同时把采集时的接触压力值作为独立特征传入。更稳妥的做法是做一个“静态压力补偿”先用加压曲线拟合幅值与压力的关系再把残差作为脉象幅度。5.4 按拍随机切分导致结果乐观到离谱现象程序里用train_test_split随机切分测试准确率95%换成GroupKFold按受试者分组后只有70%。原因同一个人的相邻脉搏波几乎一模一样随机切分会让测试集里出现训练集受试者的“亲戚”样本模型根本没在跨人泛化。解决从第一天就让每个样本携带受试者ID标注和训练默认按ID分组。报告结果时单独列出“跨人模式”也就是训练集和测试集完全没有同一个人的数据这才是这个系统真实可用时的表现。5.5 特征越堆越多、准确率反而下降现象把时域、频域、熵、统计特征全部加进随机森林50多个特征F1从0.78掉到0.74。原因脉象标注噪声大特征维度太高后模型开始拟合噪声有些特征彼此高度相关比如谱熵和能量比等于给噪声加权了。解决用随机森林的特征重要性排序取前20个或者用SelectKBest做一遍过滤。更简单的做法是从业务上限定特征只保留能说清主波、潮波、重搏波、频带能量比这四组共12~15个特征每换一个特征都要做消融对比。6. 让结果可解释用SHAP与脉象规则互相印证6.1 单样本SHAP解释模型做出来不能是黑匣子。训练完随机森林后我用shap库给每个预测样本算贡献值重点看它到底因为哪些特征被分到了某个脉象类别。import shap def explain_sample(model, X, sample_idx, feature_names): explainer shap.TreeExplainer(model) shap_values explainer.shap_values(X) shap.summary_plot(shap_values, X, feature_namesfeature_names, max_display12)逻辑说明TreeExplainer专门针对树模型计算的是每个特征对输出的边际贡献。对脉象系统来说我会逐样本做三种检查如果模型判为弦脉要看h3_over_h1是否显著低于正常范围如果判为滑脉看energy_8_12是否偏高如果两条规则互相矛盾说明样本存在标注问题或采集质量问题直接查原始波形。6.2 把模型输出和中医脉象知识做一致性检查最后一步我习惯把模型输出做成一张“共识检查单”而不是只看准确率。弦脉在物理层面对应动脉顺应性下降特征表现是潮波提前或消失、重搏波加深滑脉对应血流速度快表现为上升支陡峭、主波尖锐、高频能量偏高迟脉和数脉直接对应周期时长的差异归一化前单独记录心率即可。检查单的用法对测试集里每一个被错误分类的样本先问一句“波形的形状真的支持这个标签吗”。很多次我发现所谓的模型错误实际上是标注者把一段波形标得太勉强特征提取出来根本不含这个脉象的物理表现。这个习惯帮我避免了很多无效调参。我自己做这类项目的最后一道工序永远是画一批单拍波形图手工对着主波、潮波、重搏波的位置看特征是否真实存在再决定要不要信这个模型。这个习惯救了我太多次希望帮到你。本文还有配套的精品资源点击获取