简介:本资源是一份面向公共卫生研究者、眼科临床工作者及数据科学初学者的近视防控分析实践文档,聚焦于从真实筛查数据中挖掘影响因素并构建可落地的预测模型。文档基于成都市3~16岁青少年近视筛查数据(含单次与两次随访共3个数据集)及Orinda纵向研究子集,系统开展斯皮尔曼相关性分析,证实父母近视史、户外运动时长、阅读时长等关键变量与近视发生显著关联;进一步对比5种集成学习算法,验证随机森林在小样本、单次检查场景下的最优预测性能,支持仅凭一次验光数据量化预估未来视力变化趋势。资源为1个197KB的Word文档(.docx),内容涵盖方法论说明、统计结果图表、模型评估指标及防控建议,结构完整、引用规范、可直接用于教学参考或科研复现。目前已有171人学习下载,适合需快速掌握医学数据建模流程、理解近视多因素机制的研究人员与医学生。
1. 近视筛查数据不是体检报告堆,而是可建模的纵向健康信号源
很多学校或社区卫生中心每年收集的视力检查表——裸眼视力、矫正视力、眼轴长度、角膜曲率、父母近视史、每日户外时长、连续近距离用眼分钟数……这些看似零散的字段,实际构成了一类典型的「轻量级纵向健康观测数据」:单次测量噪声大,但群体样本量常达数千人,时间跨度覆盖学龄儿童关键发育期(6–12岁),且变量间存在明确生理关联路径。这类数据不依赖高成本设备,却足以支撑两类刚需分析:一是识别真正驱动近视进展的可干预因素(比如每天少于1.5小时户外活动比遗传背景更具预测权重),二是构建个体化风险预警模型(如对8岁儿童给出未来2年近视发生概率及置信区间)。本文聚焦真实筛查场景下的落地闭环——从原始Excel/Word表格(.docx)中结构化提取数据,清洗缺失与异常值,完成多变量因果推断检验,并部署一个能在基层校医工作站本地运行的预测脚本。所有步骤均适配无GPU的普通办公电脑,代码兼容Python 3.9+,不调用任何需注册认证的云服务。
2. 从.docx文件解析结构化筛查数据:用python-docx提取表格并校验字段语义
2.1 理解.docx中筛查数据的典型存储模式
基层单位提交的近视筛查文档极少采用数据库格式,常见三种结构:(1)单页含1个主表格,列名固定为「学号|姓名|左眼裸眼视力|右眼裸眼视力|左眼矫正视力|…」;(2)多页分年级/班级,每页1个同结构表格;(3)表格嵌套在文字描述中(如「三年级(1)班共42人,其中视力异常者17人,详细名单见下表」)。python-docx库能直接读取Word底层XML结构,但需规避.docx特有的「合并单元格」和「空行占位」陷阱——例如「父母近视史」列常以「是/否/不详」文本存在,但实际存储为带背景色的合并单元格,直接遍历table.rows会漏行。
2.2 提取表格并映射为标准字段名
以下代码针对最复杂的「多页混合结构」设计,自动识别所有表格,按页合并后统一重命名列:
from docx import Document import pandas as pd import re def extract_screening_tables(doc_path): doc = Document(doc_path) all_tables = [] for table in doc.tables: # 跳过纯格式表格(如页眉页脚) if len(table.rows) < 3 or len(table.columns) < 5: continue # 提取首行作为列名,处理合并单元格导致的None值 headers = [] for cell in table.rows[0].cells: text = cell.text.strip() # 合并单元格时,后续cell.text可能为空,取前一个非空值 if not text and headers: text = headers[-1] headers.append(text) # 构建DataFrame,跳过表头行 data = [] for row in table.rows[1:]: if len(row.cells) != len(headers): continue # 行列数不匹配,跳过异常行 row_data = [cell.text.strip() for cell in row.cells] data.append(row_data) df = pd.DataFrame(data, columns=headers) all_tables.append(df) # 合并所有表格并去重 if not all_tables: raise ValueError("未在文档中找到有效筛查表格") full_df = pd.concat(all_tables, ignore_index=True) # 标准化列名(映射常见别名到统一字段) column_mapping = { '学号': 'student_id', 'ID': 'student_id', '姓名': 'name', '左眼裸眼视力': 'l_eye_uncorrected_vision', '右眼裸眼视力': 'r_eye_uncorrected_vision', '左眼矫正视力': 'l_eye_corrected_vision', '右眼矫正视力': 'r_eye_corrected_vision', '眼轴长度(mm)': 'axial_length_mm', '角膜曲率(D)': 'corneal_curvature_dpt', '父母近视': 'parent_myopia', '父母是否近视': 'parent_myopia', '每日户外活动(h)': 'daily_outdoor_hours', '连续近距离用眼(min)': 'continuous_nearwork_min' } # 仅保留映射字典中存在的列,忽略无关列(如"备注") valid_cols = [col for col in full_df.columns if col in column_mapping] standardized_df = full_df[valid_cols].rename(columns=column_mapping) return standardized_df # 使用示例 df_raw = extract_screening_tables("基于近视筛查数据的近视影响因素分析和近视预测.docx") print(f"成功提取{len(df_raw)}条记录,字段:{list(df_raw.columns)}")提示:
.docx中数值常混杂单位(如"24.5mm")或符号(如"≥5.0"),后续清洗阶段需统一剥离。此处仅做结构提取,不进行类型转换——避免因"不详"、"-"等文本导致整列转float失败。
2.3 字段语义校验:用正则约束业务规则
视力值必须在0.1–5.3范围内(对应国际标准LogMAR 2.0至-0.3),眼轴长度应在18–26mm之间。以下函数对关键字段执行范围校验并标记异常:
def validate_vision_fields(df): def check_vision(val): if pd.isna(val): return False try: # 剥离单位和符号,提取数字 num = float(re.search(r'[-+]?\d*\.?\d+', str(val)).group()) return 0.1 <= num <= 5.3 except (ValueError, AttributeError): return False def check_axial_length(val): if pd.isna(val): return False try: num = float(re.search(r'[-+]?\d*\.?\d+', str(val)).group()) return 18.0 <= num <= 26.0 except (ValueError, AttributeError): return False # 标记异常行 df['vision_valid'] = df[['l_eye_uncorrected_vision', 'r_eye_uncorrected_vision']].apply( lambda x: check_vision(x.iloc[0]) and check_vision(x.iloc[1]), axis=1 ) df['axial_valid'] = df['axial_length_mm'].apply(check_axial_length) invalid_count = (~df['vision_valid'] | ~df['axial_valid']).sum() print(f"发现{invalid_count}条记录存在生理范围外的视力或眼轴数据") return df df_validated = validate_vision_fields(df_raw)2.3.1 为什么必须做字段语义校验?
基层录入常出现笔误:将"23.8mm"误录为"238mm",或将"5.0"视力写成"50"。若直接进入建模,此类异常值会扭曲回归系数,尤其当使用Lasso等对离群值敏感的算法时。校验不是简单删除,而是为后续插补提供依据——例如眼轴238mm明显是小数点遗漏,应修正为23.8mm而非丢弃。
3. 近视影响因素分析:用倾向得分匹配(PSM)控制混杂偏倚
3.1 为什么不能直接用线性回归找"影响因素"?
筛查数据中,户外活动时长与近视进展呈负相关,但这不等于"增加户外时间就能预防近视"。因为高户外组儿童往往家庭经济条件更好、课业压力更小、营养摄入更均衡——这些未观测变量(confounders)同时影响户外时长和近视风险。传统多元回归无法消除此类混杂偏倚,而倾向得分匹配(Propensity Score Matching, PSM)通过构造可比对照组,使因果推断更可靠。
3.2 构建倾向得分模型:Logistic回归预测干预概率
以"每日户外活动≥2小时"为干预组(treatment=1),其余为对照组(treatment=0)。协变量选择遵循临床共识:年龄、性别、父母近视史、基线裸眼视力、所在年级(代理学业压力)。代码实现如下:
from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler import numpy as np # 定义干预变量和协变量 df_psm = df_validated.copy() df_psm['treatment'] = (df_psm['daily_outdoor_hours'].fillna(0) >= 2).astype(int) # 协变量(需先处理缺失值) covariates = ['age', 'gender', 'parent_myopia', 'l_eye_uncorrected_vision', 'grade'] # 注意:此处假设原始数据已含age/grade字段;若无,需从出生日期/入学年份推算 # 填充缺失值(分类变量用众数,数值变量用中位数) for col in covariates: if df_psm[col].dtype == 'object': df_psm[col].fillna(df_psm[col].mode()[0], inplace=True) else: df_psm[col].fillna(df_psm[col].median(), inplace=True) # 编码分类变量 df_psm = pd.get_dummies(df_psm, columns=['gender', 'parent_myopia'], drop_first=True) # 准备特征矩阵 X = df_psm[covariates].dropna() # 确保无缺失 y = df_psm.loc[X.index, 'treatment'] # 标准化(提升Logistic回归稳定性) scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # 训练倾向得分模型 psm_model = LogisticRegression(max_iter=1000, solver='liblinear') psm_model.fit(X_scaled, y) # 预测倾向得分 df_psm.loc[X.index, 'propensity_score'] = psm_model.predict_proba(X_scaled)[:, 1]3.3 执行最近邻匹配并计算平均处理效应(ATE)
使用statsmodels的MatchedGroups模块实现1:1最近邻匹配(卡尺=0.02),避免匹配质量差的样本:
from statsmodels.stats.api import MatchedGroups # 分离干预组和对照组 treated = df_psm[df_psm['treatment'] == 1].copy() control = df_psm[df_psm['treatment'] == 0].copy() # 按倾向得分排序并匹配 treated_sorted = treated.sort_values('propensity_score') control_sorted = control.sort_values('propensity_score') matched_pairs = [] caliper = 0.02 for _, t_row in treated_sorted.iterrows(): # 在对照组中找最接近的倾向得分 candidates = control_sorted[ abs(control_sorted['propensity_score'] - t_row['propensity_score']) <= caliper ] if len(candidates) > 0: best_match = candidates.iloc[0] matched_pairs.append((t_row.name, best_match.name)) control_sorted = control_sorted.drop(best_match.name) # 构建匹配后数据集 matched_idx = [pair[0] for pair in matched_pairs] + [pair[1] for pair in matched_pairs] df_matched = df_psm.loc[matched_idx].copy() # 计算ATE:干预组与对照组在"2年后近视发生率"上的差异 # 假设原始数据含follow_up_myopia字段(1=发生,0=未发生) ate = (df_matched[df_matched['treatment']==1]['follow_up_myopia'].mean() - df_matched[df_matched['treatment']==0]['follow_up_myopia'].mean()) print(f"匹配后样本量:{len(df_matched)}(干预组{len(treated)}, 对照组{len(control)})") print(f"平均处理效应(ATE):{ate:.3f},即增加户外活动≥2小时可降低近视发生率{ate*100:.1f}%")3.3.1 PSM结果解读的关键陷阱
- 平衡性检验必须做:匹配后协变量在两组间的标准化差异应<0.1。若
age的差异为0.15,说明匹配未消除年龄混杂,需调整协变量或改用其他匹配方法(如核匹配)。 - 共同支撑域(Common Support):剔除倾向得分在干预组最小值与对照组最大值之外的样本,否则外推结论不可靠。代码中
caliper参数即实现此约束。
4. 近视预测模型构建:用XGBoost处理小样本高维特征并输出SHAP解释
4.1 为什么选XGBoost而非深度学习?
基层筛查数据通常仅数百至两千样本,但含10+维度(视力、生物参数、行为习惯、家族史)。深度学习需要数千样本才能避免过拟合,而XGBoost在小样本下仍保持高鲁棒性,且其树结构天然支持缺失值处理——这恰匹配筛查数据中"角膜曲率未测"、"父母近视史不详"等常见缺失模式。
4.2 特征工程:构造临床有意义的衍生变量
单纯使用原始字段会丢失生理逻辑。例如:
- 视力衰退速率=
(基线视力 - 当前视力) / 间隔月数(需有历史数据) - 屈光不正负荷=
1 / (1/左眼视力 + 1/右眼视力)(调和平均,比算术平均更符合光学原理) - 用眼负荷指数=
连续近距离用眼(min) × (1 / 户外时长(h))(量化失衡程度)
# 假设数据含基线视力和检查日期,计算衰退速率 from datetime import datetime df_pred = df_matched.copy() # 示例:构造屈光不正负荷(处理0值) def calculate_refractive_load(l_vision, r_vision): l_inv = 1 / float(l_vision) if l_vision and float(l_vision) > 0 else 0 r_inv = 1 / float(r_vision) if r_vision and float(r_vision) > 0 else 0 total_inv = l_inv + r_inv return 2 / total_inv if total_inv > 0 else 0 df_pred['refractive_load'] = df_pred.apply( lambda x: calculate_refractive_load( x['l_eye_uncorrected_vision'], x['r_eye_uncorrected_vision'] ), axis=1 ) # 构造用眼负荷指数(避免除零) df_pred['eye_strain_index'] = ( df_pred['continuous_nearwork_min'].fillna(0) / (df_pred['daily_outdoor_hours'].fillna(0.1) + 0.1) )4.3 训练XGBoost模型并生成SHAP解释
使用xgboost和shap库,确保模型可解释性满足基层医生理解需求:
import xgboost as xgb import shap # 定义预测目标:2年内近视发生(二分类) y_target = df_pred['follow_up_myopia'].fillna(0).astype(int) feature_cols = [ 'age', 'gender_M', 'parent_myopia_是', 'axial_length_mm', 'refractive_load', 'eye_strain_index', 'daily_outdoor_hours' ] X_train = df_pred[feature_cols].fillna(df_pred[feature_cols].median()) # 训练模型(小样本需抑制过拟合) model = xgb.XGBClassifier( n_estimators=100, max_depth=4, # 限制树深度,防过拟合 learning_rate=0.05, # 降低学习率,提升稳定性 subsample=0.8, # 行采样,增强泛化 colsample_bytree=0.8 # 列采样,防特征过依赖 ) model.fit(X_train, y_target) # 计算SHAP值 explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_train) # 输出单个样本的解释(供医生查看) sample_idx = 0 shap.plots.waterfall(explainer.expected_value, shap_values[sample_idx], X_train.iloc[sample_idx])4.3.1 SHAP值如何指导临床决策?
图中每个条形代表一个特征对预测结果的贡献值。例如:
- 若
axial_length_mm条形为+0.8,表示该儿童眼轴每增加1mm,近视风险提升0.8个logit单位; - 若
daily_outdoor_hours为-0.6,说明当前户外时长不足,是主要可干预靶点。
这种解释无需统计学背景即可被校医理解,直接链接到干预动作。
5. 模型部署与基层应用:用Flask构建零配置预测Web界面
5.1 将训练好的XGBoost模型序列化为本地文件
避免每次请求都重新训练,保存为.json格式(兼容跨平台):
import json # 保存模型参数和特征列表 model_dict = { "booster": model.get_booster().save_raw(), "feature_names": feature_cols, "class_names": ["未近视", "近视"] } with open("myopia_predictor.json", "w", encoding="utf-8") as f: json.dump(model_dict, f, ensure_ascii=False, indent=2) print("模型已保存为myopia_predictor.json,可直接加载使用")5.2 构建极简Flask服务:单文件、无依赖、一键启动
创建app.py,仅需Flask基础库(pip install flask),无需数据库或前端框架:
from flask import Flask, request, jsonify, render_template_string import json import xgboost as xgb import numpy as np app = Flask(__name__) # 加载模型 with open("myopia_predictor.json", "r", encoding="utf-8") as f: model_data = json.load(f) booster = xgb.Booster() booster.load_json(model_data["booster"]) @app.route('/') def home(): html = """ <!DOCTYPE html> <html><body> <h2>近视风险预测工具(基层版)</h2> <form id="predictForm"> 年龄:<input type="number" name="age" step="0.1" required><br> 性别(男=1,女=0):<input type="number" name="gender_M" min="0" max="1" required><br> 父母近视(是=1,否=0):<input type="number" name="parent_myopia_是" min="0" max="1" required><br> 眼轴长度(mm):<input type="number" name="axial_length_mm" step="0.01" required><br> 屈光负荷:<input type="number" name="refractive_load" step="0.01" required><br> 用眼负荷指数:<input type="number" name="eye_strain_index" step="0.01" required><br> 每日户外时长(h):<input type="number" name="daily_outdoor_hours" step="0.1" required><br> <button type="submit">计算风险</button> </form> <div id="result"></div> <script> document.getElementById('predictForm').onsubmit = async function(e) { e.preventDefault(); const formData = new FormData(this); const data = Object.fromEntries(formData); const res = await fetch('/predict', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) }); const result = await res.json(); document.getElementById('result').innerHTML = `<p>预测结果:${result.prediction}(概率${(result.probability*100).toFixed(1)}%)</p>`; }; </script> </body></html> """ return render_template_string(html) @app.route('/predict', methods=['POST']) def predict(): try: data = request.get_json() # 构造特征向量(顺序必须与训练时一致) features = np.array([[ float(data['age']), float(data['gender_M']), float(data['parent_myopia_是']), float(data['axial_length_mm']), float(data['refractive_load']), float(data['eye_strain_index']), float(data['daily_outdoor_hours']) ]]) # 预测 pred_proba = booster.predict(xgb.DMatrix(features))[0] prediction = "近视" if pred_proba > 0.5 else "未近视" return jsonify({ "prediction": prediction, "probability": float(pred_proba) }) except Exception as e: return jsonify({"error": str(e)}), 400 if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False) # 关闭debug,生产环境安全5.3 基层部署实操指南
- 硬件要求:Windows/macOS/Linux任意系统,4GB内存足够;无需GPU。
- 启动命令:
python app.py,浏览器访问http://localhost:5000即可使用。 - 数据隐私保障:所有计算在本地完成,输入数据不上传任何服务器。
- 更新模型:替换
myopia_predictor.json文件,重启服务即生效,无需修改代码。
注意:首次运行时,Flask会提示"WARNING: This is a development server...",这是正常提示。基层环境无需Nginx反向代理,直接使用即可——开发服务器在局域网内完全满足安全与性能要求。
本文还有配套的精品资源,点击获取