资讯详情

资讯详情

护理笔记情绪特征提升ICU死亡率预测:文本挖掘到模型实战

简介面向医疗数据挖掘与自然语言处理初学者这是一份基于MIMIC护理笔记预测ICU患者死亡率的Python项目包可用于临床预后研究、课程设计或毕业设计。压缩包共5个文件仅9KB包含数据提取SQL脚本、情感分析Python脚本、统计建模R代码与说明文档结构精简便于快速入门。已有362人学习。项目的关键点在于将非结构化的护理文本转化为可量化的情绪特征进而采用词袋模型或TF-IDF进行特征提取并用支持向量机、随机森林等模型完成死亡率预测两个R脚本则分别对应30天死亡率与总生存率分析形成从数据筛选到建模评估的完整链路。参考该资源读者可以理解医疗文本情绪分析与生存预测的代码逻辑和实验思路掌握构建预测模型的要点减少从零搭建的时间成本。1. 护理笔记情绪与ICU死亡率一条被低估的特征通道做过重症数据挖掘的人都有个共识死亡率预测的常规套路是先取SOFA评分、生命体征、实验室指标再丢进XGBoost或逻辑回归里调参。把这个套路跑烂之后你会发现模型AUC很难迈过0.85这条线因为结构化特征里真正有区分度的信息在入院24小时内基本都被榨干了。而这个项目的思路反着来它不碰生命体征表只读取护士写的护理笔记用Python把其中的情绪倾向积极、消极、焦虑、绝望量化成特征再配合常规临床字段预测ICU患者的死亡风险。它解决的痛点很具体非结构化文本里其实埋着大量结构化指标捕捉不到的“软信号”——护士写“patient appears anxious and hopeless”时往往比某个实验室指标更早暴露真实状态。这套资源适合两类人一类是正在做MIMIC相关课题但不知道从哪下手处理自由文本的研究者另一类是已经跑通过结构化死亡预测、想往多模态方向加权的从业者。它不是一个能直接交付临床的成品而是一条完整可复现的特征工程链路从原始笔记表到最终AUC对比每个环节都留了可改的口子。接下来我从数据构造开始把这条链路拆开讲。2. 任务定义与数据标准把护理笔记变成可计算的输入2.1 为什么护理笔记里的情绪词能反映死亡风险护理笔记和出院小结不一样它不是对病情的回顾性总结而是护士在每个轮班内对患者状态的实时记录。这意味着它天然带有时间颗粒度优势可能比实验室指标早6到12小时捕捉到恶化信号。情感分析在这个场景里不是要做通用情绪分类而是提取“绝望感”“焦虑感”“依从性差”这类与预后相关的语义维度。常见做法是先在MIMIC-III或MIMIC-IV的NOTEEVENTS表里筛出护理相关的note再按患者ID和入院ID对齐到住院记录。标签直接用HOSPITAL_EXPIRE_FLAG0表示存活出院1表示院内死亡。这里容易犯的第一个错是直接拿整条note做情感打分但一条护理记录往往长达数百词里面既有主观判断、也有客观观察甚至还有交接班的模板问候语这些噪音会直接把情感均值拉向中性。我在复现时采用的分层策略是先按换行符或句号把长note切成片段再对每个片段做情感打分最后按患者ID聚合。这样做的好处是不会因为长notes里的情感分布不均而全部抹平。import pandas as pd import re from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer notes pd.read_csv(mimic_nursing_notes.csv) notes[text] notes[text].fillna() # 把长笔记切成句子级片段保留id用于聚合 def split_sentences(text): parts re.split(r(?[.!?])\s|\n, text) return [p.strip() for p in parts if len(p.strip()) 3] notes[segments] notes[text].apply(split_sentences) analyzer SentimentIntensityAnalyzer() rows [] for _, row in notes.iterrows(): for seg in row[segments]: scores analyzer.polarity_scores(seg) rows.append({ note_id: row[note_id], subject_id: row[subject_id], hadm_id: row[hadm_id], segment: seg, neg: scores[neg], neu: scores[neu], pos: scores[pos], compound: scores[compound], expire_flag: row[expire_flag], }) seg_df pd.DataFrame(rows)逻辑说明这里的核心不是直接对全量note做情感分析而是先切句因为VADER这类工具对短文本更稳定超过200词后得分会被模板话术冲淡。参数上我保留了neg、neu、pos、compound四路输出而不是只取compound因为死前期患者通常同时存在高焦虑neg高和低积极表达pos低两路综合比单看均值更有效。语句切分用的是正则(?[.!?])\s只按英文标点后跟空白切分不会误伤ICU记录里常见的缩写如“q2h”“BID”等信息。长度过滤设的是大于3个字符过滤掉单字母无意义片段。2.2 聚合层按入院记录汇总情感密度而不是简单平均片段级情绪分拿到后下一个决策点是聚合方式。我按hadm_id住院记录ID聚合生成每条住院记录对应的情感特征。特征不只是“平均neg分”而是拆成五类片段数、neg均值、pos均值、compound均值、neg片段占比。最后两类尤其有用因为它们考量的是“护士是否频繁用消极词”而非单条note的情绪振幅。agg_dict { compound: [mean, std], neg: [mean, max], pos: [mean], segment: [count], } feat_df seg_df.groupby([hadm_id, subject_id, expire_flag]).agg(agg_dict).reset_index() feat_df.columns [hadm_id, subject_id, expire_flag, cm_avg, cm_std, neg_avg, neg_max, pos_avg, seg_count] feat_df[neg_ratio] (feat_df[neg_avg] 0.05).astype(float) feat_df.to_csv(sentiment_features.csv, indexFalse)逻辑说明groupby按hadm_id做聚合能把同一住院期的多次护理记录合并成一条样本下游建模更干净。我用neg_max而不只取均值是因为高危患者在恶化前往往有一到两条笔记展现出极端的消极情绪峰值。neg_ratio的计算方式是判断平均neg分是否超过0.05这个滑动阈值具体阈值可以按自己数据的分布微调。这个特征本质上是“负面情绪密度”与单纯的平均值是两套信息量。特征合并到结局标签时要注意方向expire_flag是1的样本只是少数如果直接建模会让模型偏向预测存活后续需要做样本类别权重或下采样处理。2.3 数据切分与验证模式按患者切别按行切护理笔记天然存在同一患者多次入院的纵向相关性如果按行随机切分同一个患者的不同住院记录会同时出现在训练集和测试集里模型等于提前见过这个人。切分必须按subject_id做group split确保同一患者的所有记录只落在一个集合内。from sklearn.model_selection import GroupShuffleSplit subject_ids feat_df[[subject_id, hadm_id, expire_flag]].drop_duplicates() splitter GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, test_idx next(splitter.split(subject_ids, groupssubject_ids[subject_id])) train_subjects subject_ids.iloc[train_idx][subject_id] test_subjects subject_ids.iloc[test_idx][subject_id] train_df feat_df[feat_df[subject_id].isin(train_subjects)].reset_index(dropTrue) test_df feat_df[feat_df[subject_id].isin(test_subjects)].reset_index(dropTrue) print(train size:, len(train_df), test size:, len(test_df))逻辑说明GroupShuffleSplit的groups参数直接传subject_id确保同一患者的记录不会被切开。random_state固定为42这是MIMIC系列任务里的常用惯例方便横向对比复现。如果不做group split模型性能会虚高3到5个点的AUC这在后续做消融实验时会明显暴露出来。3. 情感特征工程深入VADER、医疗词典与时间窗的取舍3.1 VADER能不能直接用在医疗文本上能但要做两处适配VADER是社交网络文本上训练出来的规则型情感词典它对表情符号、俚语和程度副词处理得很好但在医疗场景有两个明显的错位一是护理笔记里大量词是中性描述“respiratory failure”“sepsis”但在词典里被标注为消极二是缩写和医学术语“SOB”既可以是shortness of breath也可以是俚语会干扰打分。我的调整思路是保留VADER作为基线条但同时引入医疗领域的情感词典做加权。具体操作是把AFFIN词典中与医疗无关的词过滤掉再与VADER的输出做线性组合。如果不想引入额外依赖也可以自己建一个ICU特别词表比如“deteriorating”“withdrawn”“combative”“unresponsive”这类词直接映射到neg倾向。icu_neg_words { deteriorating: -0.7, withdrawn: -0.6, combative: -0.5, unresponsive: -0.6, hypotensive: -0.4, agonal: -0.9, } icu_pos_words { stable: 0.5, improving: 0.6, alert: 0.3, extubated: 0.7, ambulating: 0.4, } def medical_sentiment_adj(compound_score, text): words set(re.findall(r[a-zA-Z], text.lower())) adjust 0.0 neg_hits len(words set(icu_neg_words.keys())) pos_hits len(words set(icu_pos_words.keys())) adjust neg_hits * (-0.2) pos_hits * (0.2) return compound_score adjust feat_df[cm_adj] feat_df[hadm_id].map(seg_df.groupby(hadm_id)[compound].apply( lambda x: np.mean([medical_sentiment_adj(v, .join(seg_df.loc[seg_df[hadm_id] i, segment])) for i, v in zip(x.index, x)]) ))逻辑说明这个适配层做的是规则补强不是替代。它把ICU护理笔记里常出现、但VADER不一定敏感的词直接加权到片段分上。neg_hits和pos_hits的权重是0.2这个数值来自我自己的调参经验——太大会让规则词表主导结果太小等于没调。每个词的初始权重放在字典值里比如“agonal”是临终相关词权重给到-0.9命中一次就显著拉低得分。实际复现时你不需要照抄这个字典可以先跑一版纯VADER特征再对照bad case预测错误的样本看哪些词频繁出现但未被正确打分再把这些词加入自定义表。3.2 为什么不用BERT直接全量跑很多人一上来就想着用预训练BERT模型把每条note编码成768维向量再分类。这个思路本身没问题但在复用这个项目时计算成本和可解释性都值得先算笔账MIMIC-III里的nursing note总数是20万到30万条级别即使过滤掉极短记录单机用CPU跑BERT推理也是几小时起步而且下游死亡率预测用的样本量不过几千条住院记录高维BERT向量很容易过拟合。更实际的路线是“情感特征主干少量BERT特征辅助”。我一般采用的做法是只对最终死亡或存活群体代表性差别的那些片段做编码比如用TF-IDF筛出top 100个关键词再看这些词在两类样本中的分布差异然后把差异最大的若干词频作为补充特征。from sklearn.feature_extraction.text import TfidfVectorizer notes_all seg_df.groupby(hadm_id)[segment].apply(lambda x: .join(x)).reset_index() notes_all.columns [hadm_id, full_text] tfidf TfidfVectorizer(max_features500, ngram_range(1, 2), min_df5, stop_wordsenglish) tfidf_matrix tfidf.fit_transform(notes_all[full_text]) imp_words pd.DataFrame(tfidf_matrix.toarray(), columnstfidf.get_feature_names_out()) imp_words[hadm_id] notes_all[hadm_id].values imp_words imp_words.merge(feat_df[[hadm_id, expire_flag]], onhadm_id, howleft) # 按存活/死亡两组找差异最大的词 from scipy.stats import mannwhitneyu results [] for col in imp_words.columns[:-2]: group_dead imp_words.loc[imp_words[expire_flag] 1, col] group_alive imp_words.loc[imp_words[expire_flag] 0, col] stat, p mannwhitneyu(group_dead, group_alive, alternativetwo-sided) results.append({word: col, p_value: p}) diff_df pd.DataFrame(results).sort_values(p_value).head(30) print(diff_df)逻辑说明这个做法的目的不是用全量TF-IDF特征训练模型而是用统计学检验来定位哪些词在死亡组和存活组之间有显著分布差异再把差异词作为情感特征的辅助解释变量。这里用Mann-Whitney U检验而不是t检验是因为词频分布远不是正态的t检验的结果会偏向高频词。min_df参数设为5避免只在几条笔记出现一次的罕见词制造假阳性。p值排序后输出前30个词你可以拿这份词表去对照VADER打分看有哪些词不在词典里但实际区分度很高补充进上一个代码块的自定义词典。3.3 时间窗笔记聚合时要不要按入院小时数截断护理笔记是时间序列数据入院后第6小时的笔记和住院第10天的笔记对死亡预测的边际贡献完全不一样。靠近出院或死亡的那几小时笔记充满了“terminal”相关的描述如果直接全部聚合等于把结局信息泄露进了特征。合理的做法是设定截断点比如只取入院后24小时或48小时内的笔记。具体代码是用MIMIC的表里CHARTTIME字段减去入院时间ADMITTIME做差值过滤。# 在SQL阶段完成过滤避免全量导出后再处理 # 以MIMIC-IV为例的连接方式 SELECT n.subject_id, n.hadm_id, n.charttime, a.admittime, n.text, a.hospital_expire_flag FROM mimiciv_note.note n INNER JOIN mimiciv_hosp.admissions a ON n.subject_id a.subject_id AND n.hadm_id a.hadm_id WHERE n.charttime IS NOT NULL AND n.charttime a.admittime AND n.charttime a.admittime INTERVAL 48 HOUR AND LOWER(n.category) LIKE %nursing%;逻辑说明这个SQL只提取入院后48小时内的护理笔记。48是经验值——太短样本量不足太长会混入后期发生的事件。如果你做的课题是早期预警方向通常推荐24小时如果你做的是全住院期风险分层72小时也常被使用。关键在于截断后测试集里后来发生的笔记不会被用于预测这是在时间维度上避免信息泄漏的核心手段。空值CHARTTIME的条目需要直接丢弃因为它们无法确定是否发生在截断点前。4. 建模与对比多种方案验证情感特征的真实增益4.1 基线模型只用结构化特征时的AUC先建一个不包含任何文本特征的结构化基线这样后面加的每路情感特征增益才有参照系。基线变量选择入ICU时的年龄、性别、收缩压、心率、体温、GCS评分、SOFA评分、是否急诊入院。这些字段在MIMIC里可以直接从CHARTEVENTS和 admissions表里抽。from xgboost import XGBClassifier from sklearn.metrics import roc_auc_score from sklearn.model_selection import GridSearchCV base_features [age, gender, sbp, hr, temp, gcs, sofa, emergency_admit] X_train_base train_df[base_features].copy() y_train train_df[expire_flag].astype(int) X_test_base test_df[base_features].copy() y_test test_df[expire_flag].astype(int) model_base XGBClassifier( n_estimators200, max_depth4, learning_rate0.05, subsample0.8, colsample_bytree0.8, random_state42 ) model_base.fit(X_train_base, y_train, eval_set[(X_test_base, y_test)], verboseFalse) pred_base model_base.predict_proba(X_test_base)[:, 1] auc_base roc_auc_score(y_test, pred_base) print(Baseline AUC:, round(auc_base, 4))逻辑说明基线模型用XGBoost是常见做法树模型对缺失值容忍度高适合MIMIC这类字段缺失普遍的数据集。max_depth设到4而不是更大是防止在小样本上过拟合。注意MIMIC的SOFA评分本身有较多缺失值XGBoost会自动处理但如果你换用逻辑回归需要先做插补。4.2 引入情感特征AUC提升了多少我用文本特征的组合方式有三种变体只加情感特征、只加关键词差异特征、情感加关键词混合。每种变体都跑一遍记录AUC变化。# 构造混合特征 sentiment_cols [cm_avg, cm_std, neg_avg, neg_max, pos_avg, neg_ratio] keyword_cols diff_df[word].tolist() imp_words_selected imp_words.set_index(hadm_id)[keyword_cols].fillna(0) imp_words_selected.columns [kw_ c for c in imp_words_selected.columns] train_feat train_df.merge(imp_words_selected, left_onhadm_id, right_indexTrue, howleft).fillna(0) test_feat test_df.merge(imp_words_selected, left_onhadm_id, right_indexTrue, howleft).fillna(0) X_train_senti train_feat[base_features sentiment_cols].copy() X_test_senti test_feat[base_features sentiment_cols].copy() model_senti XGBClassifier( n_estimators200, max_depth4, learning_rate0.05, subsample0.8, colsample_bytree0.8, random_state42 ) model_senti.fit(X_train_senti, y_train, eval_set[(X_test_senti, y_test)], verboseFalse) pred_senti model_senti.predict_proba(X_test_senti)[:, 1] auc_senti roc_auc_score(y_test, pred_senti) print(Sentiment AUC:, round(auc_senti, 4)) print(AUC Lift:, round(auc_senti - auc_base, 4))逻辑说明混合特征的构造逻辑是把情感聚合特征和关键词差异特征同时并入。关键在于合并时用hadm_id做left join且未匹配的样本用0填充。为什么能补0因为关键词特征本质上是词频没出现的文档词频就是0这个缺失是语义上的缺失而非随机的不需要插补。最终实验跑下来的经验数值是情感特征能把AUC从0.83推到0.85左右而关键词差异特征还能再贡献0.2到0.5个百分点。# 加关键词差异特征 X_train_mix train_feat[base_features sentiment_cols keyword_cols].copy() X_test_mix test_feat[base_features sentiment_cols keyword_cols].copy() model_mix XGBClassifier( n_estimators300, max_depth5, learning_rate0.03, subsample0.85, colsample_bytree0.85, random_state42 ) model_mix.fit(X_train_mix, y_train, eval_set[(X_test_mix, y_test)], verboseFalse) pred_mix model_mix.predict_proba(X_test_mix)[:, 1] auc_mix roc_auc_score(y_test, pred_mix) print(Mixed AUC:, round(auc_mix, 4))逻辑说明混合模板调整了三个参数n_estimators提到300、max_depth到5、learning_rate降到0.03。这是为了在特征维度增加后给模型更多拟合空间同时用更低的学习率防止一步跨过最优位置。主观经验是这类任务中关键词特征的加入会让树模型更倾向于在词频分支上做分裂所以配合colsample_bytree降为0.85控制列采样比例。4.3 校准曲线与阈值选择预测概率需要后处理吗ICU死亡率预测有个实际约束模型输出的概率不是最终使用值临床使用时需要设定把多少分以上的患者判定为“高风险”。此时需要画校准曲线看预测概率和实际频率之间是否一致。XGBoost的原始输出通常偏向中值附近当样本不平衡时概率会被压缩到0.2到0.6这个区间。from sklearn.calibration import calibration_curve prob_true, prob_pred calibration_curve(y_test, pred_mix, n_bins10) for t, p in zip(prob_pred, prob_true): print(fpred: {t:.3f}, actual: {p:.3f})逻辑说明n_bins10将预测概率分成10份每份里算实际正样本比例。通常运行结果会显示低分组预测0.1里实际死亡比例在0.15左右高分组预测0.7里实际死亡比例在0.6左右这说明模型有区分度但概率没有校准到绝对刻度。如果目标是做“进入风险组即启动干预”的决策流程建议对预测结果做Platt缩放或等距回归校准。阈值的选择则根据成本权衡如果干预手段是低风险的加强监护取约0.3能提高召回如果干预是插管这类高风险操作阈值应上调到0.6以上。5. 避坑与常见问题排查四个必踩的深坑记录5.1 没有按患者切分导致AUC虚高现象按行随机切分后AUC高达0.9换用group split切分后跌回0.84。这时候第一反应应该是切分方式出了问题而不是模型调参不当。原因同一患者的多次住院记录在语义特征上高度相似如果训练和测试集各占一条模型等于在“默写”它见过的患者的情绪模式而不是在泛化识别。解决严格按subject_id做GroupShuffleSplit测试集必须包含训练集中从未出现过的患者ID。验证时还要打印两组subject_id的交集确认交集为空。复现这个项目时我在第二次迭代中就把这个校验加进了数据管线后续所有集合的对比不再出现这类虚高。5.2 SQL截断时误用dischtime而不是admittime现象使用dischtime减CHARTTIME的差值过滤后训练集AUC完全没有提升且高分段样本全是住院后期患者。原因dischtime是出院或死亡时间用它反推48小时会选到临终期记录这类笔记充满“agonal”“no response”等极消极词汇相当于把结局信息直接写进了特征里。解决统一使用admittime作为时间零点表达式写成charttime admittime AND charttime admittime INTERVAL 48 HOUR。如果使用48小时截断还需要在后续建模中记录各样本的笔记实际覆盖时间跨度小于12小时的样本可以考虑单独标记因为它们的信息量天然不足。5.3 VADER对医疗缩写和否定表达处理走偏现象笔记中出现“denies chest pain”时VADER给整句打正分但实际语义是患者在描述阴性症状情感倾向偏负。原因VADER依赖句内词汇极性“denies”本身不被识别为否定词变形同时“chest pain”里的“pain”被标为负向词但“denies chest pain”整体表达的是“没有胸痛”这一相对积极状态默认规则分辨不了。解决对片段先做简单语义规则预处理。具体做法是维护两个列表——否定前缀词列表denies、no、without、negative for和症状词列表当片段同时命中两者时做一次情感极性翻转。类似“no acute distress”这类否定中性词组合处理后不会把负向权重误加到患者状态评估上。5.4 情感特征与结局标签之间的时间重叠现象模型AUC在测试集上比验证集高但精度低进一步排查后发现大量死亡患者的笔记集中在死亡前24小时内。原因这是“结果引导文本”现象——医护人员在患者状态明显恶化后会倾向于写更多消极描述此时情感特征本质上就是标签的浅层映射训练出的模型只学会了识别“患者快不行了”这些文字的措辞。解决按死亡时间反向剔除临终期笔记。常规划线是把死亡前24小时内的笔记从特征提取阶段直接排除只保留入院到死亡前24小时之间的文本。同理存活患者则保留全部住院期间笔记。我在实际复现时还追加了一个敏感性分析分别用“排除死亡前12小时”“排除死亡前24小时”“排除死亡前48小时”三组数据跑同一套模型记录AUC衰减曲线如果衰减趋势平稳说明特征不是单纯从临终措辞中学习。6. 进阶用法把情感特征与结构化时序做融合再喂给轻量级模型做端到端验证情感特征单独使用的增益上限大约在1到2个AUC点但更大的价值在于当你需要做多模态建模时它可以作为通路打通“自由文本结构化数据”的接口。我常做的一件事是把情感特征按时序切段后再与生命体征序列做对齐。例如将入院后每12小时划为一个窗口窗口内分别计算情感均值、情感方差、SOFA评分均值、心率斜率然后把这些窗口特征拼接成一条序列用LSTM或Transformer编码后再分类。这种做法的好处是保留时序信息的同时避免文本嵌入对计算资源的依赖。import numpy as np def build_window_features(seg_df, feat_df, window_hours12, max_windows12): window_rows [] for hadm_id, notes_g in seg_df.groupby(hadm_id): chart_times notes_g[chart_hour].values notes_g[window_idx] (chart_times // window_hours).astype(int) # 情感统计按窗口聚合 for win, win_g in notes_g.groupby(window_idx): window_rows.append({ hadm_id: hadm_id, window_idx: win, cm_mean: win_g[compound].mean(), neg_mean: win_g[neg].mean(), pos_mean: win_g[pos].mean(), seg_count: len(win_g), }) win_df pd.DataFrame(window_rows) # 与生命体征序列对齐 vitals feat_df.groupby([hadm_id, window_idx])[[hr, sbp, sofa]].mean().reset_index() merged win_df.merge(vitals, on[hadm_id, window_idx], howinner) # 截断到窗口数量 merged merged[merged[window_idx] max_windows] return merged win_feat build_window_features(seg_df, feat_df)逻辑说明窗口化的目标是把“情感”变成一条随时间变化的曲线而不是一个静态数值。params中window_hours12设成长宽是考虑到护士笔记的书写频率通常是每8到12小时一篇太小的窗口会大量空置max_windows12把序列长度限制到144小时覆盖了绝大多数住院记录的关键观察期。如果患者不足144小时后面的窗口值为空可在送入LSTM时补零并用mask遮罩。最后做一次消融对比纯窗口情感序列、纯生命体征序列、情感加体征的混合序列分别训练同一个时序模型记录AUC。这一步能验证融合的真实收益边界。跑这类任务的个人教训是所有从护理笔记里提取出来的情感波形都要先看一眼分布再进模型。有一版实验中某窗口的情感均值中位数全部落在0.0到0.05之间我当时以为情感特征没用后来发现是VADER对ICU词汇的敏感度不足调了自定义词典之后分布才拉开。从那以后每次做文本特征我都会先打印聚合特征在两组标签下的分位数对比特征无效先怀疑提取方式再怀疑模型结构。希望这份拆解能帮你少走这几步弯路。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →