G2PInsight 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- G2PInsight/__init__.py +10 -0
- G2PInsight/bin/__init__.py +0 -0
- G2PInsight/bin/font_utils.py +135 -0
- G2PInsight/bin/gemma_gwas.py +402 -0
- G2PInsight/bin/modeltraining.py +2671 -0
- G2PInsight/bin/plink_ld.py +433 -0
- G2PInsight/bin/preprocess.py +2903 -0
- G2PInsight/bin/software/gemma-0.98.5-linux-static-AMD64 +0 -0
- G2PInsight/bin/software/plink +0 -0
- G2PInsight/bin/visualization.py +1692 -0
- G2PInsight/main.py +453 -0
- g2pinsight-1.0.0.dist-info/METADATA +451 -0
- g2pinsight-1.0.0.dist-info/RECORD +17 -0
- g2pinsight-1.0.0.dist-info/WHEEL +5 -0
- g2pinsight-1.0.0.dist-info/entry_points.txt +2 -0
- g2pinsight-1.0.0.dist-info/licenses/LICENSE +21 -0
- g2pinsight-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2671 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Model training module (适配preprocess元数据衔接)
|
|
4
|
+
支持多模型训练、结果可视化、预测等功能
|
|
5
|
+
- GWAS特征筛选和LD过滤已前移到preprocess模块
|
|
6
|
+
- 训练阶段仅使用preprocess已筛选的特征
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
import shutil
|
|
16
|
+
import atexit
|
|
17
|
+
import signal
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional, Dict, List, Tuple, Any
|
|
20
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
21
|
+
import warnings
|
|
22
|
+
warnings.filterwarnings('ignore')
|
|
23
|
+
|
|
24
|
+
import pandas as pd
|
|
25
|
+
import numpy as np
|
|
26
|
+
from sklearn.model_selection import KFold, StratifiedKFold, RandomizedSearchCV
|
|
27
|
+
from sklearn.inspection import permutation_importance
|
|
28
|
+
from sklearn.metrics import (
|
|
29
|
+
accuracy_score, precision_score, recall_score, f1_score, roc_auc_score,
|
|
30
|
+
mean_squared_error, mean_absolute_error, r2_score
|
|
31
|
+
)
|
|
32
|
+
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
|
|
33
|
+
from sklearn.svm import SVC, SVR
|
|
34
|
+
from sklearn.linear_model import LogisticRegression, LinearRegression
|
|
35
|
+
import lightgbm as lgb
|
|
36
|
+
import xgboost as xgb
|
|
37
|
+
from catboost import CatBoostClassifier, CatBoostRegressor
|
|
38
|
+
|
|
39
|
+
# SHAP 支持(可选)
|
|
40
|
+
try:
|
|
41
|
+
import shap
|
|
42
|
+
SHAP_AVAILABLE = True
|
|
43
|
+
except ImportError:
|
|
44
|
+
SHAP_AVAILABLE = False
|
|
45
|
+
|
|
46
|
+
# ======================== 绘图质量固定开关(standardized) ========================
|
|
47
|
+
# 固定实现:不再通过命令行或函数参数切换;所有训练/可视化产物统一使用期刊质量配置。
|
|
48
|
+
PUB_QUALITY_MODE: bool = True
|
|
49
|
+
|
|
50
|
+
# ======================== 调试控制开关 ========================
|
|
51
|
+
# 设置为 False 时:保留模型训练过程中产生的所有临时文件和目录,方便排查问题
|
|
52
|
+
# 设置为 True 时:恢复原来的自动清理临时文件逻辑
|
|
53
|
+
CLEANUP_TEMP_FILES = True
|
|
54
|
+
|
|
55
|
+
# ======================== 全局临时文件管理器 ========================
|
|
56
|
+
class TempFileManager:
|
|
57
|
+
"""
|
|
58
|
+
全局临时文件管理器,用于注册和清理临时文件/目录
|
|
59
|
+
支持程序退出时自动清理(包括正常退出、异常退出、信号中断)
|
|
60
|
+
"""
|
|
61
|
+
def __init__(self):
|
|
62
|
+
self.temp_files: List[Path] = []
|
|
63
|
+
self.temp_dirs: List[Path] = []
|
|
64
|
+
self.preprocess_tmp_dirs: List[Path] = [] # preprocess阶段的临时目录(tmp_p,不清理)
|
|
65
|
+
self._cleanup_registered = False
|
|
66
|
+
self._cleaned = False # 标记是否已清理,防止重复清理
|
|
67
|
+
self._register_exit_handlers()
|
|
68
|
+
|
|
69
|
+
def _register_exit_handlers(self):
|
|
70
|
+
"""注册退出处理函数"""
|
|
71
|
+
if self._cleanup_registered:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# 注册 atexit 处理函数(正常退出时调用)
|
|
75
|
+
atexit.register(self.cleanup_on_exit)
|
|
76
|
+
|
|
77
|
+
# 注册信号处理函数(SIGINT: Ctrl+C, SIGTERM: 终止信号)
|
|
78
|
+
try:
|
|
79
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
80
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
81
|
+
except (ValueError, OSError):
|
|
82
|
+
# 在某些环境下(如线程中)可能无法注册信号处理器,忽略错误
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
self._cleanup_registered = True
|
|
86
|
+
|
|
87
|
+
def _signal_handler(self, signum, frame):
|
|
88
|
+
"""信号处理器"""
|
|
89
|
+
logger.warning(f"Received signal {signum}, cleaning up temporary files...")
|
|
90
|
+
self.cleanup_on_exit()
|
|
91
|
+
# 重新发送信号,让程序正常退出
|
|
92
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
93
|
+
os.kill(os.getpid(), signum)
|
|
94
|
+
|
|
95
|
+
def register_file(self, file_path: Path) -> Path:
|
|
96
|
+
"""注册临时文件"""
|
|
97
|
+
file_path = Path(file_path).absolute()
|
|
98
|
+
if file_path not in self.temp_files:
|
|
99
|
+
self.temp_files.append(file_path)
|
|
100
|
+
return file_path
|
|
101
|
+
|
|
102
|
+
def register_dir(self, dir_path: Path) -> Path:
|
|
103
|
+
"""注册临时目录"""
|
|
104
|
+
dir_path = Path(dir_path).absolute()
|
|
105
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
if dir_path not in self.temp_dirs:
|
|
107
|
+
self.temp_dirs.append(dir_path)
|
|
108
|
+
return dir_path
|
|
109
|
+
|
|
110
|
+
def register_preprocess_tmp_dir(self, dir_path: Path) -> Path:
|
|
111
|
+
"""注册preprocess阶段的临时目录(tmp_p,不清理,preprocess模块执行完毕不删除)"""
|
|
112
|
+
dir_path = Path(dir_path).absolute()
|
|
113
|
+
if dir_path not in self.preprocess_tmp_dirs:
|
|
114
|
+
self.preprocess_tmp_dirs.append(dir_path)
|
|
115
|
+
return dir_path
|
|
116
|
+
|
|
117
|
+
def cleanup_on_exit(self, cleanup_preprocess: bool = False):
|
|
118
|
+
"""
|
|
119
|
+
清理所有注册的临时文件和目录
|
|
120
|
+
|
|
121
|
+
:param cleanup_preprocess: 是否清理preprocess阶段的临时目录(默认False,preprocess模块执行完毕不删除tmp_p目录)
|
|
122
|
+
"""
|
|
123
|
+
if not CLEANUP_TEMP_FILES:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
# 防止重复清理:如果已经清理过临时文件/目录,且本次不清理preprocess,则跳过
|
|
127
|
+
if self._cleaned and not cleanup_preprocess:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
# 1. 清理临时文件(如果尚未清理)
|
|
132
|
+
if not self._cleaned:
|
|
133
|
+
for fp in self.temp_files:
|
|
134
|
+
try:
|
|
135
|
+
if fp.exists():
|
|
136
|
+
fp.unlink()
|
|
137
|
+
except Exception as e:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
# 2. 清理临时目录(逆序删除,确保子目录先删除)
|
|
141
|
+
for dp in reversed(self.temp_dirs):
|
|
142
|
+
try:
|
|
143
|
+
if dp.exists():
|
|
144
|
+
shutil.rmtree(dp, ignore_errors=True)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
# 清空列表
|
|
149
|
+
self.temp_files.clear()
|
|
150
|
+
self.temp_dirs.clear()
|
|
151
|
+
self._cleaned = True
|
|
152
|
+
|
|
153
|
+
# 3. 清理preprocess阶段的临时目录(默认不清理,preprocess模块执行完毕不删除tmp_p目录)
|
|
154
|
+
if cleanup_preprocess:
|
|
155
|
+
for dp in reversed(self.preprocess_tmp_dirs):
|
|
156
|
+
try:
|
|
157
|
+
if dp.exists():
|
|
158
|
+
shutil.rmtree(dp, ignore_errors=True)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
pass
|
|
161
|
+
self.preprocess_tmp_dirs.clear()
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.warning(f"Error during temporary file cleanup (ignored): {e}")
|
|
165
|
+
|
|
166
|
+
def clear(self):
|
|
167
|
+
"""清空所有注册的临时文件/目录(不删除)"""
|
|
168
|
+
self.temp_files.clear()
|
|
169
|
+
self.temp_dirs.clear()
|
|
170
|
+
self.preprocess_tmp_dirs.clear()
|
|
171
|
+
self._cleaned = False
|
|
172
|
+
|
|
173
|
+
# 创建全局临时文件管理器实例
|
|
174
|
+
_temp_file_manager = TempFileManager()
|
|
175
|
+
|
|
176
|
+
# ======================== 日志配置 ========================
|
|
177
|
+
logger = logging.getLogger(__name__)
|
|
178
|
+
logging.basicConfig(
|
|
179
|
+
level=logging.INFO,
|
|
180
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
181
|
+
stream=sys.stdout
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# ======================== 1. 工具函数:元数据读取 & 输入解析 ========================
|
|
185
|
+
def load_preprocess_metadata(metadata_file: str) -> Dict:
|
|
186
|
+
"""读取preprocess生成的JSON元数据文件"""
|
|
187
|
+
if not Path(metadata_file).exists():
|
|
188
|
+
raise FileNotFoundError(f"Preprocess metadata file not found: {metadata_file}")
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
with open(metadata_file, 'r', encoding='utf-8') as f:
|
|
192
|
+
metadata = json.load(f)
|
|
193
|
+
except json.JSONDecodeError as e:
|
|
194
|
+
raise ValueError(f"Invalid metadata JSON format: {metadata_file}, error: {str(e)}")
|
|
195
|
+
|
|
196
|
+
# 验证核心字段(gwas_genotype_prefix可以为None,表示文件不存在)
|
|
197
|
+
required_fields = ["valid_samples", "output_train_file"]
|
|
198
|
+
missing_fields = [f for f in required_fields if f not in metadata]
|
|
199
|
+
if missing_fields:
|
|
200
|
+
raise ValueError(f"Metadata missing required fields: {', '.join(missing_fields)}")
|
|
201
|
+
|
|
202
|
+
# 以前这里会验证 gwas_genotype_prefix 对应的 PLINK 文件是否存在,并在缺失时输出 warning。
|
|
203
|
+
# 目前 model training 阶段不再依赖这些中间 PLINK 文件,为避免干扰用户,这里不再做存在性检查。
|
|
204
|
+
# 如需使用 GWAS/LD 相关的 PLINK 输出,请在对应模块中单独验证路径。
|
|
205
|
+
|
|
206
|
+
return metadata
|
|
207
|
+
|
|
208
|
+
def parse_train_input_path(input_path: str) -> Dict:
|
|
209
|
+
"""解析训练输入路径,支持元数据文件/训练文件两种模式"""
|
|
210
|
+
input_path = Path(input_path).absolute().as_posix()
|
|
211
|
+
result = {
|
|
212
|
+
"train_file": None,
|
|
213
|
+
"metadata": None,
|
|
214
|
+
"gwas_genotype_prefix": None,
|
|
215
|
+
"task_type": None, # 从元数据读取的task_type
|
|
216
|
+
"preprocess_tmp_dir": None, # 从元数据读取的preprocess临时目录
|
|
217
|
+
"_metadata_file_path": None # 元数据文件路径,用于路径解析
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# 模式1:输入是元数据文件
|
|
221
|
+
if input_path.endswith("_metadata.json"):
|
|
222
|
+
metadata = load_preprocess_metadata(input_path)
|
|
223
|
+
result["metadata"] = metadata
|
|
224
|
+
result["train_file"] = metadata["output_train_file"]
|
|
225
|
+
result["gwas_genotype_prefix"] = metadata.get("gwas_genotype_prefix")
|
|
226
|
+
result["task_type"] = metadata.get("task_type") # 从元数据读取task_type
|
|
227
|
+
result["preprocess_tmp_dir"] = metadata.get("preprocess_tmp_dir") # 从元数据读取preprocess临时目录
|
|
228
|
+
result["_metadata_file_path"] = input_path # 保存元数据文件路径
|
|
229
|
+
# 模式2:输入是训练文件,尝试关联元数据
|
|
230
|
+
else:
|
|
231
|
+
result["train_file"] = input_path
|
|
232
|
+
# 尝试多种可能的元数据文件路径
|
|
233
|
+
train_file_path = Path(input_path)
|
|
234
|
+
possible_metadata_paths = [
|
|
235
|
+
f"{os.path.splitext(input_path)[0]}_metadata.json", # train_data.txt -> train_data_metadata.json
|
|
236
|
+
str(train_file_path.parent / f"{train_file_path.stem}_metadata.json"), # 同上,但使用Path
|
|
237
|
+
str(train_file_path.parent / f"{train_file_path.parent.name}_metadata.json"), # 如果输出目录名作为前缀
|
|
238
|
+
]
|
|
239
|
+
# 也尝试查找目录下所有metadata.json文件
|
|
240
|
+
if train_file_path.parent.exists():
|
|
241
|
+
for metadata_file in train_file_path.parent.glob("*_metadata.json"):
|
|
242
|
+
possible_metadata_paths.append(str(metadata_file))
|
|
243
|
+
|
|
244
|
+
metadata_found = False
|
|
245
|
+
for metadata_path in possible_metadata_paths:
|
|
246
|
+
if Path(metadata_path).exists():
|
|
247
|
+
try:
|
|
248
|
+
metadata = load_preprocess_metadata(metadata_path)
|
|
249
|
+
result["metadata"] = metadata
|
|
250
|
+
result["gwas_genotype_prefix"] = metadata.get("gwas_genotype_prefix")
|
|
251
|
+
result["task_type"] = metadata.get("task_type") # 从元数据读取task_type
|
|
252
|
+
result["preprocess_tmp_dir"] = metadata.get("preprocess_tmp_dir")
|
|
253
|
+
result["_metadata_file_path"] = metadata_path # 保存元数据文件路径
|
|
254
|
+
metadata_found = True
|
|
255
|
+
break
|
|
256
|
+
except Exception as e:
|
|
257
|
+
continue # 尝试下一个路径
|
|
258
|
+
|
|
259
|
+
if not metadata_found:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
# 验证训练文件存在性
|
|
263
|
+
if not Path(result["train_file"]).exists():
|
|
264
|
+
raise FileNotFoundError(f"Training file does not exist: {result['train_file']}")
|
|
265
|
+
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
# ======================== 2. 数据加载 & 处理 ========================
|
|
269
|
+
def clean_feature_names(feature_names: List[str]) -> List[str]:
|
|
270
|
+
"""
|
|
271
|
+
清理特征名称,移除LightGBM不支持的特殊JSON字符
|
|
272
|
+
|
|
273
|
+
LightGBM不支持的特征名称字符:{ } [ ] , : " ' 等JSON特殊字符
|
|
274
|
+
"""
|
|
275
|
+
import re
|
|
276
|
+
# 定义需要替换的特殊字符(JSON特殊字符)
|
|
277
|
+
special_chars = r'[{}\[\]\,"\':]'
|
|
278
|
+
|
|
279
|
+
cleaned_names = []
|
|
280
|
+
for name in feature_names:
|
|
281
|
+
# 替换特殊字符为下划线
|
|
282
|
+
cleaned = re.sub(special_chars, '_', str(name))
|
|
283
|
+
# 移除连续的下划线
|
|
284
|
+
cleaned = re.sub(r'_+', '_', cleaned)
|
|
285
|
+
# 移除开头和结尾的下划线
|
|
286
|
+
cleaned = cleaned.strip('_')
|
|
287
|
+
# 如果清理后为空,使用原始名称的哈希值
|
|
288
|
+
if not cleaned:
|
|
289
|
+
cleaned = f"feature_{hash(name) % 1000000}"
|
|
290
|
+
cleaned_names.append(cleaned)
|
|
291
|
+
|
|
292
|
+
return cleaned_names
|
|
293
|
+
|
|
294
|
+
def load_training_data(train_file: str, valid_samples: List[str] = None) -> Tuple[pd.DataFrame, pd.Series, Dict[str, str]]:
|
|
295
|
+
"""
|
|
296
|
+
加载训练数据,复用preprocess的有效样本
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
X: 特征DataFrame(列名已清理)
|
|
300
|
+
y: 目标变量Series
|
|
301
|
+
snp_name_mapping: 原始SNP名称到清理后名称的映射字典
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
# 检查文件格式
|
|
305
|
+
train_file_path = Path(train_file)
|
|
306
|
+
if not train_file_path.exists():
|
|
307
|
+
raise FileNotFoundError(f"Training data file not found: {train_file}")
|
|
308
|
+
|
|
309
|
+
file_ext = train_file_path.suffix.lower()
|
|
310
|
+
if file_ext in ['.vcf', '.vcf.gz']:
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"VCF file detected: {train_file}\n"
|
|
313
|
+
f"Error: Model training/prediction requires preprocessed training data format (tab-separated .txt file with 'sample' column as index).\n"
|
|
314
|
+
f"VCF files must be preprocessed first using the 'preprocess' command.\n"
|
|
315
|
+
f"Please run: G2PInsight preprocess -g {train_file} -p <phenotype_file> -o <output_dir>\n"
|
|
316
|
+
f"Then use the preprocessed output file for training/prediction."
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# 检测是否是压缩文件
|
|
320
|
+
is_compressed = file_ext == '.gz' or train_file.endswith('.gz')
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
# 尝试自动检测分隔符
|
|
324
|
+
# 先读取前几行来检测分隔符
|
|
325
|
+
import csv
|
|
326
|
+
import gzip
|
|
327
|
+
|
|
328
|
+
# 根据是否压缩选择打开方式
|
|
329
|
+
# 注意:gzip 文件不支持 seek(0),所以需要重新打开文件
|
|
330
|
+
if is_compressed:
|
|
331
|
+
with gzip.open(train_file, 'rt', encoding='utf-8') as f:
|
|
332
|
+
# 读取前10行来检测分隔符(跳过空行)
|
|
333
|
+
sample_lines = []
|
|
334
|
+
for _ in range(10):
|
|
335
|
+
line = f.readline()
|
|
336
|
+
if line.strip(): # 跳过空行
|
|
337
|
+
sample_lines.append(line)
|
|
338
|
+
if not line: # 文件结束
|
|
339
|
+
break
|
|
340
|
+
else:
|
|
341
|
+
with open(train_file, 'r', encoding='utf-8') as f:
|
|
342
|
+
# 读取前10行来检测分隔符(跳过空行)
|
|
343
|
+
sample_lines = []
|
|
344
|
+
for _ in range(10):
|
|
345
|
+
line = f.readline()
|
|
346
|
+
if line.strip(): # 跳过空行
|
|
347
|
+
sample_lines.append(line)
|
|
348
|
+
if not line: # 文件结束
|
|
349
|
+
break
|
|
350
|
+
f.seek(0) # 重置文件指针(仅对非压缩文件有效)
|
|
351
|
+
|
|
352
|
+
# 检查是否读取到数据
|
|
353
|
+
if not sample_lines:
|
|
354
|
+
raise ValueError(f"File {train_file} appears to be empty or contains only empty lines")
|
|
355
|
+
|
|
356
|
+
# 检测最常见的分隔符
|
|
357
|
+
# 使用csv.Sniffer来检测分隔符(更准确)
|
|
358
|
+
delimiter = "\t" # 默认使用制表符
|
|
359
|
+
try:
|
|
360
|
+
# 尝试使用csv.Sniffer自动检测
|
|
361
|
+
sample_text = "".join(sample_lines[:5]) # 使用前5行
|
|
362
|
+
sniffer = csv.Sniffer()
|
|
363
|
+
detected_delimiter = sniffer.sniff(sample_text, delimiters="\t, ").delimiter
|
|
364
|
+
if detected_delimiter:
|
|
365
|
+
delimiter = detected_delimiter
|
|
366
|
+
except Exception as e:
|
|
367
|
+
# 手动检测:统计每种分隔符出现的次数
|
|
368
|
+
delimiter_counts = {"\t": [], ",": [], " ": []}
|
|
369
|
+
|
|
370
|
+
for line in sample_lines:
|
|
371
|
+
if not line.strip():
|
|
372
|
+
continue
|
|
373
|
+
delimiter_counts["\t"].append(line.count("\t"))
|
|
374
|
+
delimiter_counts[","].append(line.count(","))
|
|
375
|
+
delimiter_counts[" "].append(line.count(" "))
|
|
376
|
+
|
|
377
|
+
# 计算每种分隔符的平均出现次数(排除0值)
|
|
378
|
+
delimiter_avg = {}
|
|
379
|
+
for sep, counts in delimiter_counts.items():
|
|
380
|
+
non_zero_counts = [c for c in counts if c > 0]
|
|
381
|
+
if non_zero_counts:
|
|
382
|
+
delimiter_avg[sep] = sum(non_zero_counts) / len(non_zero_counts)
|
|
383
|
+
else:
|
|
384
|
+
delimiter_avg[sep] = 0
|
|
385
|
+
|
|
386
|
+
# 选择平均出现次数最多的分隔符(但至少要有2个字段)
|
|
387
|
+
max_avg = max(delimiter_avg.values())
|
|
388
|
+
if max_avg > 0:
|
|
389
|
+
for sep, avg_count in delimiter_avg.items():
|
|
390
|
+
if avg_count == max_avg and avg_count > 0:
|
|
391
|
+
delimiter = sep
|
|
392
|
+
break
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# 验证检测到的分隔符:检查第一行使用该分隔符后有多少字段
|
|
396
|
+
if sample_lines:
|
|
397
|
+
first_line = sample_lines[0].strip()
|
|
398
|
+
field_count = len(first_line.split(delimiter))
|
|
399
|
+
|
|
400
|
+
# 如果字段数异常(太多或太少),尝试其他分隔符
|
|
401
|
+
if field_count > 1000 or field_count < 2:
|
|
402
|
+
logger.warning(f"Detected field count ({field_count}) seems abnormal, trying alternative delimiters")
|
|
403
|
+
for alt_sep in ["\t", ",", " "]:
|
|
404
|
+
if alt_sep != delimiter:
|
|
405
|
+
alt_count = len(first_line.split(alt_sep))
|
|
406
|
+
if 2 <= alt_count <= 1000:
|
|
407
|
+
logger.info(f"Switching to delimiter {repr(alt_sep)} (field count: {alt_count})")
|
|
408
|
+
delimiter = alt_sep
|
|
409
|
+
break
|
|
410
|
+
|
|
411
|
+
# 读取数据(sample列为索引,最后一列为表型)
|
|
412
|
+
# 使用更健壮的读取方式,处理格式不一致的情况
|
|
413
|
+
# pandas 的 read_csv 会自动处理 gzip 压缩文件(如果文件名以 .gz 结尾)
|
|
414
|
+
df = None
|
|
415
|
+
read_errors = []
|
|
416
|
+
|
|
417
|
+
# 策略1: 尝试使用检测到的分隔符,跳过错误行
|
|
418
|
+
try:
|
|
419
|
+
df = pd.read_csv(
|
|
420
|
+
train_file,
|
|
421
|
+
sep=delimiter,
|
|
422
|
+
index_col="sample",
|
|
423
|
+
na_filter=False, # 禁用缺失值过滤(preprocess已处理)
|
|
424
|
+
engine='python', # 使用Python引擎,更健壮但稍慢,支持压缩文件
|
|
425
|
+
on_bad_lines='skip', # 跳过格式错误的行(pandas >= 1.3.0)
|
|
426
|
+
compression='gzip' if is_compressed else 'infer' # 明确指定压缩格式
|
|
427
|
+
)
|
|
428
|
+
except (TypeError, ValueError) as e:
|
|
429
|
+
read_errors.append(f"Strategy 1 (on_bad_lines='skip'): {str(e)}")
|
|
430
|
+
# 策略2: 对于旧版本的pandas,使用不同的参数
|
|
431
|
+
try:
|
|
432
|
+
df = pd.read_csv(
|
|
433
|
+
train_file,
|
|
434
|
+
sep=delimiter,
|
|
435
|
+
index_col="sample",
|
|
436
|
+
na_filter=False,
|
|
437
|
+
engine='python',
|
|
438
|
+
error_bad_lines=False, # pandas < 1.3.0
|
|
439
|
+
warn_bad_lines=True,
|
|
440
|
+
compression='gzip' if is_compressed else 'infer'
|
|
441
|
+
)
|
|
442
|
+
except (TypeError, ValueError) as e2:
|
|
443
|
+
read_errors.append(f"Strategy 2 (error_bad_lines=False): {str(e2)}")
|
|
444
|
+
# 策略3: 使用最基本的读取方式,不跳过错误行
|
|
445
|
+
try:
|
|
446
|
+
df = pd.read_csv(
|
|
447
|
+
train_file,
|
|
448
|
+
sep=delimiter,
|
|
449
|
+
index_col="sample",
|
|
450
|
+
na_filter=False,
|
|
451
|
+
engine='python',
|
|
452
|
+
compression='gzip' if is_compressed else 'infer'
|
|
453
|
+
)
|
|
454
|
+
except Exception as e3:
|
|
455
|
+
read_errors.append(f"Strategy 3 (basic mode): {str(e3)}")
|
|
456
|
+
# 策略4: 尝试使用C引擎(更快,但可能不够健壮,且可能不支持压缩)
|
|
457
|
+
if not is_compressed: # C引擎可能不支持压缩文件
|
|
458
|
+
try:
|
|
459
|
+
df = pd.read_csv(
|
|
460
|
+
train_file,
|
|
461
|
+
sep=delimiter,
|
|
462
|
+
index_col="sample",
|
|
463
|
+
na_filter=False,
|
|
464
|
+
engine='c',
|
|
465
|
+
low_memory=False
|
|
466
|
+
)
|
|
467
|
+
except Exception as e4:
|
|
468
|
+
read_errors.append(f"Strategy 4 (C engine): {str(e4)}")
|
|
469
|
+
else:
|
|
470
|
+
read_errors.append(f"Strategy 4 (C engine): Skipped (compressed file, C engine may not support)")
|
|
471
|
+
# 如果所有策略都失败,检查是否是缺少 "sample" 列的问题
|
|
472
|
+
error_msg = f"Failed to read file {train_file} with delimiter {repr(delimiter)}. Tried strategies:\n"
|
|
473
|
+
error_msg += "\n".join(f" - {err}" for err in read_errors)
|
|
474
|
+
|
|
475
|
+
# 检查是否是缺少 "sample" 列的问题
|
|
476
|
+
if any("Index sample invalid" in err or "sample" in err.lower() for err in read_errors):
|
|
477
|
+
# 尝试读取第一行来检查列名
|
|
478
|
+
try:
|
|
479
|
+
with open(train_file, 'r', encoding='utf-8') as f:
|
|
480
|
+
header_line = f.readline().strip()
|
|
481
|
+
if delimiter:
|
|
482
|
+
columns = header_line.split(delimiter)
|
|
483
|
+
if 'sample' not in columns:
|
|
484
|
+
error_msg += f"\n\nError: File does not contain 'sample' column as required.\n"
|
|
485
|
+
error_msg += f"Found columns: {columns[:10]}{'...' if len(columns) > 10 else ''}\n"
|
|
486
|
+
error_msg += f"This file format is not compatible with model training/prediction.\n"
|
|
487
|
+
error_msg += f"Expected format: Tab-separated file with 'sample' column as index (from preprocess module output).\n"
|
|
488
|
+
if file_ext in ['.vcf', '.vcf.gz']:
|
|
489
|
+
error_msg += f"Note: VCF files must be preprocessed first using: G2PInsight preprocess -g <vcf_file> -p <phenotype_file> -o <output_dir>"
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
error_msg += f"\n\nPlease check the file format. Expected delimiter: {repr(delimiter)}"
|
|
494
|
+
logger.error(error_msg)
|
|
495
|
+
raise ValueError(error_msg) from e4
|
|
496
|
+
|
|
497
|
+
if df is None or df.empty:
|
|
498
|
+
raise ValueError(f"Failed to read data from {train_file} or file is empty")
|
|
499
|
+
|
|
500
|
+
# 验证文件格式:确保有 "sample" 索引
|
|
501
|
+
if df.index.name != "sample" and "sample" not in str(df.index.name).lower():
|
|
502
|
+
# 检查是否有 sample 列
|
|
503
|
+
if "sample" in df.columns:
|
|
504
|
+
logger.warning("Found 'sample' column but not as index. Attempting to set it as index...")
|
|
505
|
+
df = df.set_index("sample")
|
|
506
|
+
else:
|
|
507
|
+
raise ValueError(
|
|
508
|
+
f"File {train_file} does not have 'sample' column/index as required.\n"
|
|
509
|
+
f"Current index name: {df.index.name}\n"
|
|
510
|
+
f"Current columns: {list(df.columns[:10])}{'...' if len(df.columns) > 10 else ''}\n"
|
|
511
|
+
f"This file format is not compatible with model training/prediction.\n"
|
|
512
|
+
f"Expected format: Tab-separated file with 'sample' column as index (from preprocess module output)."
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# 复用preprocess的有效样本,减少数据量
|
|
516
|
+
if valid_samples:
|
|
517
|
+
df = df.loc[df.index.isin(valid_samples)]
|
|
518
|
+
|
|
519
|
+
# 拆分特征和目标变量
|
|
520
|
+
# 确保排除phenotype列(无论它在哪个位置)
|
|
521
|
+
if 'phenotype' in df.columns:
|
|
522
|
+
X = df.drop(columns=['phenotype'])
|
|
523
|
+
y = df['phenotype']
|
|
524
|
+
else:
|
|
525
|
+
# 如果没有phenotype列名,假设最后一列是表型
|
|
526
|
+
X = df.iloc[:, :-1] # 前n-1列:SNP特征
|
|
527
|
+
y = df.iloc[:, -1] # 最后一列:表型(分类/回归)
|
|
528
|
+
|
|
529
|
+
# 进一步过滤:排除任何包含"phenotype"的列名
|
|
530
|
+
X = filter_phenotype_from_dataframe(X)
|
|
531
|
+
|
|
532
|
+
# ======================== 关键修改:统一SNP特征命名 ========================
|
|
533
|
+
# 目的:让训练和预测阶段的特征命名保持一致(chr_pos),
|
|
534
|
+
# 避免出现训练使用 1_4392528 而预测使用 1_4392528_A 这种不匹配情况。
|
|
535
|
+
import re
|
|
536
|
+
pattern_colon = re.compile(r'^([^:]+):([^_]+)_[^_]+$') # 1:223238_A -> (1, 223238)
|
|
537
|
+
pattern_under = re.compile(r'^([^_]+)_([^_]+)_[^_]+$') # 1_223238_A -> (1, 223238)
|
|
538
|
+
|
|
539
|
+
renamed_cols: List[str] = []
|
|
540
|
+
for col in X.columns:
|
|
541
|
+
col_str = str(col)
|
|
542
|
+
chr_part = pos_part = None
|
|
543
|
+
|
|
544
|
+
m = pattern_colon.match(col_str)
|
|
545
|
+
if m:
|
|
546
|
+
chr_part, pos_part = m.group(1), m.group(2)
|
|
547
|
+
else:
|
|
548
|
+
m2 = pattern_under.match(col_str)
|
|
549
|
+
if m2:
|
|
550
|
+
chr_part, pos_part = m2.group(1), m2.group(2)
|
|
551
|
+
|
|
552
|
+
if chr_part and pos_part:
|
|
553
|
+
renamed_cols.append(f"{chr_part}_{pos_part}")
|
|
554
|
+
else:
|
|
555
|
+
renamed_cols.append(col_str)
|
|
556
|
+
|
|
557
|
+
X.columns = renamed_cols
|
|
558
|
+
|
|
559
|
+
# 清理特征名称(移除LightGBM不支持的特殊字符)
|
|
560
|
+
original_cols = X.columns.tolist()
|
|
561
|
+
cleaned_cols = clean_feature_names(original_cols)
|
|
562
|
+
|
|
563
|
+
# 创建SNP名称映射(原始名称 -> 清理后名称)
|
|
564
|
+
snp_name_mapping = {}
|
|
565
|
+
|
|
566
|
+
# 检查是否有重复的特征名称(清理后可能产生重复)
|
|
567
|
+
if len(cleaned_cols) != len(set(cleaned_cols)):
|
|
568
|
+
from collections import Counter, defaultdict
|
|
569
|
+
col_counts = Counter(cleaned_cols)
|
|
570
|
+
col_indices = defaultdict(int)
|
|
571
|
+
final_cols = []
|
|
572
|
+
|
|
573
|
+
for i, cleaned_col in enumerate(cleaned_cols):
|
|
574
|
+
if col_counts[cleaned_col] > 1:
|
|
575
|
+
# 如果有重复,添加索引后缀
|
|
576
|
+
col_indices[cleaned_col] += 1
|
|
577
|
+
final_col = f"{cleaned_col}_{col_indices[cleaned_col]}"
|
|
578
|
+
else:
|
|
579
|
+
final_col = cleaned_col
|
|
580
|
+
|
|
581
|
+
final_cols.append(final_col)
|
|
582
|
+
snp_name_mapping[original_cols[i]] = final_col
|
|
583
|
+
|
|
584
|
+
X.columns = final_cols
|
|
585
|
+
else:
|
|
586
|
+
X.columns = cleaned_cols
|
|
587
|
+
# 创建映射
|
|
588
|
+
for orig, cleaned in zip(original_cols, cleaned_cols):
|
|
589
|
+
snp_name_mapping[orig] = cleaned
|
|
590
|
+
|
|
591
|
+
# 数据合法性校验
|
|
592
|
+
if X.empty or y.empty:
|
|
593
|
+
raise ValueError("Loaded training data is empty")
|
|
594
|
+
if X.isnull().any().any():
|
|
595
|
+
X = X.fillna(-1)
|
|
596
|
+
|
|
597
|
+
return X, y, snp_name_mapping
|
|
598
|
+
except Exception as e:
|
|
599
|
+
logger.error(f"Failed to load training data: {str(e)}")
|
|
600
|
+
raise
|
|
601
|
+
|
|
602
|
+
# ======================== 3. 模型训练 & 评估 ========================
|
|
603
|
+
def init_model(
|
|
604
|
+
model_type: str,
|
|
605
|
+
task_type: str,
|
|
606
|
+
random_state: int = 42,
|
|
607
|
+
cpu_cores: Optional[int] = None,
|
|
608
|
+
) -> Any:
|
|
609
|
+
"""初始化模型(分类/回归)"""
|
|
610
|
+
common_params = {"random_state": random_state}
|
|
611
|
+
# 为了避免并行进程下的线程超配,对支持多线程的模型显式设置线程数
|
|
612
|
+
# 单位:CPU 核心/线程数。最少为 1。
|
|
613
|
+
if cpu_cores is None:
|
|
614
|
+
cpu_cores_value = None
|
|
615
|
+
else:
|
|
616
|
+
cpu_cores_value = max(1, int(cpu_cores))
|
|
617
|
+
|
|
618
|
+
# 模型类映射(减少重复代码)
|
|
619
|
+
model_classes = {
|
|
620
|
+
"classification": {
|
|
621
|
+
"LightGBM": lgb.LGBMClassifier,
|
|
622
|
+
"RandomForest": RandomForestClassifier,
|
|
623
|
+
"XGBoost": xgb.XGBClassifier,
|
|
624
|
+
"SVM": SVC,
|
|
625
|
+
"CatBoost": CatBoostClassifier,
|
|
626
|
+
"Logistic": LogisticRegression
|
|
627
|
+
},
|
|
628
|
+
"regression": {
|
|
629
|
+
"LightGBM": lgb.LGBMRegressor,
|
|
630
|
+
"RandomForest": RandomForestRegressor,
|
|
631
|
+
"XGBoost": xgb.XGBRegressor,
|
|
632
|
+
"SVM": SVR,
|
|
633
|
+
"CatBoost": CatBoostRegressor,
|
|
634
|
+
"Logistic": LinearRegression
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
# 模型参数配置
|
|
639
|
+
model_params = {
|
|
640
|
+
"LightGBM": {
|
|
641
|
+
"n_estimators": 100,
|
|
642
|
+
"learning_rate": 0.1,
|
|
643
|
+
"num_leaves": 31,
|
|
644
|
+
"verbose": -1,
|
|
645
|
+
# 控制 LightGBM 的并行线程数,避免在多进程并发时超配
|
|
646
|
+
**({} if cpu_cores_value is None else {"n_jobs": cpu_cores_value}),
|
|
647
|
+
**common_params
|
|
648
|
+
},
|
|
649
|
+
"RandomForest": {
|
|
650
|
+
"n_estimators": 100,
|
|
651
|
+
"max_depth": None,
|
|
652
|
+
# 控制随机森林并行线程数;默认保持单线程,避免单模型时也产生过度并行
|
|
653
|
+
"n_jobs": cpu_cores_value if cpu_cores_value is not None else 1,
|
|
654
|
+
**common_params
|
|
655
|
+
},
|
|
656
|
+
"XGBoost": {
|
|
657
|
+
"n_estimators": 100,
|
|
658
|
+
"learning_rate": 0.1,
|
|
659
|
+
"max_depth": 6,
|
|
660
|
+
"verbosity": 0,
|
|
661
|
+
# 控制 XGBoost 的并行线程数
|
|
662
|
+
**({} if cpu_cores_value is None else {"n_jobs": cpu_cores_value}),
|
|
663
|
+
**common_params
|
|
664
|
+
},
|
|
665
|
+
"SVM": {
|
|
666
|
+
"kernel": "rbf",
|
|
667
|
+
**({"probability": True} if task_type == "classification" else {})
|
|
668
|
+
# 注意:SVM不支持random_state参数
|
|
669
|
+
},
|
|
670
|
+
"CatBoost": {
|
|
671
|
+
"iterations": 100, # 使用iterations而不是n_estimators,与参数网格保持一致
|
|
672
|
+
"learning_rate": 0.1,
|
|
673
|
+
"verbose": 0,
|
|
674
|
+
# [CATBOOST_CLEAN] 禁止 CatBoost 在当前工作目录创建 catboost_info/
|
|
675
|
+
"allow_writing_files": False,
|
|
676
|
+
# 控制 CatBoost 线程数(不同版本参数名可能略有差异,这里使用 thread_count)
|
|
677
|
+
**({} if cpu_cores_value is None else {"thread_count": cpu_cores_value}),
|
|
678
|
+
**common_params
|
|
679
|
+
},
|
|
680
|
+
"Logistic": {
|
|
681
|
+
# 控制 LogisticRegression 的并行线程数;默认保持单线程,避免单模型时也产生过度并行
|
|
682
|
+
"n_jobs": cpu_cores_value if cpu_cores_value is not None else 1,
|
|
683
|
+
**({"max_iter": 1000} if task_type == "classification" else {}),
|
|
684
|
+
**({} if task_type == "regression" else common_params)
|
|
685
|
+
# 注意:LinearRegression不支持random_state参数
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if task_type not in model_classes:
|
|
690
|
+
raise ValueError(f"Unsupported task type: {task_type}; available: {list(model_classes.keys())}")
|
|
691
|
+
|
|
692
|
+
if model_type not in model_classes[task_type]:
|
|
693
|
+
raise ValueError(f"Unsupported model type: {model_type}; available: {list(model_classes[task_type].keys())}")
|
|
694
|
+
|
|
695
|
+
# 获取模型类和参数
|
|
696
|
+
model_class = model_classes[task_type][model_type]
|
|
697
|
+
params = model_params[model_type]
|
|
698
|
+
|
|
699
|
+
model = model_class(**params)
|
|
700
|
+
# [CATBOOST_CLEAN_FALLBACK] 兜底:个别版本/配置下仍可能生成 catboost_info,
|
|
701
|
+
# 这里尽量关闭写文件(若不支持该属性则忽略)
|
|
702
|
+
try:
|
|
703
|
+
if model_type == "CatBoost" and hasattr(model, "set_params"):
|
|
704
|
+
model.set_params(allow_writing_files=False)
|
|
705
|
+
except Exception:
|
|
706
|
+
pass
|
|
707
|
+
return model
|
|
708
|
+
|
|
709
|
+
def get_param_grid(model_type: str, task_type: str) -> Dict:
|
|
710
|
+
"""
|
|
711
|
+
获取模型的超参数网格(用于网格搜索)
|
|
712
|
+
|
|
713
|
+
:param model_type: 模型类型
|
|
714
|
+
:param task_type: 任务类型(classification/regression)
|
|
715
|
+
:return: 参数字典
|
|
716
|
+
"""
|
|
717
|
+
# 公共参数网格(分类和回归相同)
|
|
718
|
+
common_param_grids = {
|
|
719
|
+
"LightGBM": {
|
|
720
|
+
'n_estimators': [50, 100, 200],
|
|
721
|
+
'learning_rate': [0.01, 0.1, 0.2],
|
|
722
|
+
'num_leaves': [15, 31, 63],
|
|
723
|
+
'max_depth': [3, 5, 7, -1],
|
|
724
|
+
'min_child_samples': [10, 20, 30],
|
|
725
|
+
'subsample': [0.8, 0.9, 1.0],
|
|
726
|
+
'colsample_bytree': [0.8, 0.9, 1.0]
|
|
727
|
+
},
|
|
728
|
+
"RandomForest": {
|
|
729
|
+
'n_estimators': [50, 100, 200],
|
|
730
|
+
'max_depth': [5, 10, 20, None],
|
|
731
|
+
'min_samples_split': [2, 5, 10],
|
|
732
|
+
'min_samples_leaf': [1, 2, 4],
|
|
733
|
+
'max_features': ['sqrt', 'log2', None]
|
|
734
|
+
},
|
|
735
|
+
"XGBoost": {
|
|
736
|
+
'n_estimators': [50, 100, 200],
|
|
737
|
+
'learning_rate': [0.01, 0.1, 0.2],
|
|
738
|
+
'max_depth': [3, 5, 7],
|
|
739
|
+
'min_child_weight': [1, 3, 5],
|
|
740
|
+
'subsample': [0.8, 0.9, 1.0],
|
|
741
|
+
'colsample_bytree': [0.8, 0.9, 1.0],
|
|
742
|
+
'gamma': [0, 0.1, 0.2]
|
|
743
|
+
},
|
|
744
|
+
"CatBoost": {
|
|
745
|
+
'iterations': [50, 100, 200],
|
|
746
|
+
'learning_rate': [0.01, 0.1, 0.2],
|
|
747
|
+
'depth': [4, 6, 8],
|
|
748
|
+
'l2_leaf_reg': [1, 3, 5],
|
|
749
|
+
'border_count': [32, 64, 128]
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
# 任务特定的参数网格
|
|
754
|
+
if task_type == "classification":
|
|
755
|
+
task_specific = {
|
|
756
|
+
"SVM": {
|
|
757
|
+
'C': [0.1, 1, 10, 100],
|
|
758
|
+
'gamma': ['scale', 'auto', 0.001, 0.01, 0.1],
|
|
759
|
+
'kernel': ['rbf', 'poly', 'sigmoid']
|
|
760
|
+
},
|
|
761
|
+
"Logistic": {
|
|
762
|
+
'C': [0.01, 0.1, 1, 10, 100],
|
|
763
|
+
'penalty': ['l1', 'l2', 'elasticnet'],
|
|
764
|
+
'solver': ['liblinear', 'lbfgs', 'saga'],
|
|
765
|
+
'max_iter': [500, 1000, 2000]
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
else: # regression
|
|
769
|
+
task_specific = {
|
|
770
|
+
"SVM": {
|
|
771
|
+
'C': [0.1, 1, 10, 100],
|
|
772
|
+
'gamma': ['scale', 'auto', 0.001, 0.01, 0.1],
|
|
773
|
+
'kernel': ['rbf', 'poly', 'sigmoid'],
|
|
774
|
+
'epsilon': [0.01, 0.1, 0.2]
|
|
775
|
+
},
|
|
776
|
+
"Logistic": {
|
|
777
|
+
'fit_intercept': [True, False]
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
# 合并公共和任务特定的参数网格
|
|
782
|
+
param_grids = {**common_param_grids, **task_specific}
|
|
783
|
+
|
|
784
|
+
if model_type not in param_grids:
|
|
785
|
+
raise ValueError(f"Unsupported model type: {model_type}; available: {list(param_grids.keys())}")
|
|
786
|
+
|
|
787
|
+
return param_grids[model_type]
|
|
788
|
+
|
|
789
|
+
def perform_grid_search(
|
|
790
|
+
model_type: str,
|
|
791
|
+
task_type: str,
|
|
792
|
+
X_train: pd.DataFrame,
|
|
793
|
+
y_train: pd.Series,
|
|
794
|
+
param_grid: Dict,
|
|
795
|
+
n_iter: int = 20,
|
|
796
|
+
cv: int = 3,
|
|
797
|
+
random_state: int = 42,
|
|
798
|
+
scoring: Optional[str] = None,
|
|
799
|
+
cpu_cores: Optional[int] = None,
|
|
800
|
+
) -> Any:
|
|
801
|
+
"""
|
|
802
|
+
执行超参数网格搜索
|
|
803
|
+
|
|
804
|
+
:param model_type: 模型类型
|
|
805
|
+
:param task_type: 任务类型
|
|
806
|
+
:param X_train: 训练特征
|
|
807
|
+
:param y_train: 训练标签
|
|
808
|
+
:param param_grid: 参数网格
|
|
809
|
+
:param n_iter: RandomizedSearchCV的迭代次数
|
|
810
|
+
:param cv: 交叉验证折数(用于网格搜索)
|
|
811
|
+
:param random_state: 随机种子
|
|
812
|
+
:param scoring: 评分指标(None则使用默认)
|
|
813
|
+
:return: 最佳模型
|
|
814
|
+
"""
|
|
815
|
+
# 初始化基础模型
|
|
816
|
+
base_model = init_model(
|
|
817
|
+
model_type,
|
|
818
|
+
task_type,
|
|
819
|
+
random_state=random_state,
|
|
820
|
+
cpu_cores=cpu_cores,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
# 设置默认评分指标
|
|
824
|
+
if scoring is None:
|
|
825
|
+
if task_type == "classification":
|
|
826
|
+
scoring = 'roc_auc' if len(y_train.unique()) == 2 else 'f1_weighted'
|
|
827
|
+
else:
|
|
828
|
+
scoring = 'neg_mean_squared_error'
|
|
829
|
+
|
|
830
|
+
# 使用RandomizedSearchCV(比GridSearchCV更快)
|
|
831
|
+
# 限制搜索次数以避免过长时间
|
|
832
|
+
# 注意:如果在ProcessPoolExecutor的子进程中调用,n_jobs应设为1以避免嵌套并行和资源泄漏
|
|
833
|
+
import os
|
|
834
|
+
n_jobs_value = -1 # 默认使用所有核心
|
|
835
|
+
|
|
836
|
+
# 检查是否在multiprocessing的子进程中
|
|
837
|
+
try:
|
|
838
|
+
from multiprocessing import current_process
|
|
839
|
+
process_name = current_process().name
|
|
840
|
+
# 如果进程名不是'MainProcess',说明在子进程中,使用n_jobs=1避免嵌套并行
|
|
841
|
+
if process_name != 'MainProcess':
|
|
842
|
+
n_jobs_value = 1
|
|
843
|
+
except Exception:
|
|
844
|
+
# 如果无法检测,保守地使用n_jobs=1(避免资源泄漏)
|
|
845
|
+
# 但为了性能,只在明确检测到子进程时才使用n_jobs=1
|
|
846
|
+
pass
|
|
847
|
+
|
|
848
|
+
search = RandomizedSearchCV(
|
|
849
|
+
estimator=base_model,
|
|
850
|
+
param_distributions=param_grid,
|
|
851
|
+
n_iter=n_iter,
|
|
852
|
+
cv=cv,
|
|
853
|
+
scoring=scoring,
|
|
854
|
+
n_jobs=n_jobs_value,
|
|
855
|
+
random_state=random_state,
|
|
856
|
+
verbose=0
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
search.fit(X_train, y_train)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
return search.best_estimator_
|
|
863
|
+
|
|
864
|
+
def evaluate_model(y_true: pd.Series, y_pred: np.ndarray, y_prob: np.ndarray, task_type: str) -> Dict:
|
|
865
|
+
"""模型评估(分类/回归)"""
|
|
866
|
+
metrics = {}
|
|
867
|
+
if task_type == "classification":
|
|
868
|
+
# 分类指标:AUC、召回率、F1得分、准确率
|
|
869
|
+
try:
|
|
870
|
+
# 确保y_true和y_pred是numpy数组且长度一致
|
|
871
|
+
y_true_array = _ensure_numpy_array(y_true)
|
|
872
|
+
y_pred_array = _ensure_numpy_array(y_pred)
|
|
873
|
+
|
|
874
|
+
if len(y_true_array) == 0 or len(y_pred_array) == 0:
|
|
875
|
+
logger.warning(" True values or predictions are empty, cannot calculate metrics")
|
|
876
|
+
metrics["accuracy"] = "N/A"
|
|
877
|
+
metrics["recall"] = "N/A"
|
|
878
|
+
metrics["f1"] = "N/A"
|
|
879
|
+
elif len(y_true_array) != len(y_pred_array):
|
|
880
|
+
logger.warning(f" True values and predictions length mismatch: {len(y_true_array)} vs {len(y_pred_array)}")
|
|
881
|
+
metrics["accuracy"] = "N/A"
|
|
882
|
+
metrics["recall"] = "N/A"
|
|
883
|
+
metrics["f1"] = "N/A"
|
|
884
|
+
else:
|
|
885
|
+
metrics["accuracy"] = round(accuracy_score(y_true_array, y_pred_array), 4)
|
|
886
|
+
metrics["recall"] = round(recall_score(y_true_array, y_pred_array, average="weighted"), 4)
|
|
887
|
+
metrics["f1"] = round(f1_score(y_true_array, y_pred_array, average="weighted"), 4)
|
|
888
|
+
except Exception as e:
|
|
889
|
+
logger.warning(f" Failed to calculate classification metrics: {str(e)}")
|
|
890
|
+
metrics["accuracy"] = "N/A"
|
|
891
|
+
metrics["recall"] = "N/A"
|
|
892
|
+
metrics["f1"] = "N/A"
|
|
893
|
+
try:
|
|
894
|
+
# 处理多分类和二分类的AUC计算
|
|
895
|
+
if y_prob is not None and len(y_prob) > 0:
|
|
896
|
+
from sklearn.metrics import roc_auc_score
|
|
897
|
+
|
|
898
|
+
# 确保y_true和y_prob是numpy数组
|
|
899
|
+
y_true_array = _ensure_numpy_array(y_true)
|
|
900
|
+
y_prob_array = _ensure_numpy_array(y_prob)
|
|
901
|
+
|
|
902
|
+
# 检查y_prob的形状
|
|
903
|
+
if len(y_prob_array.shape) == 1:
|
|
904
|
+
y_prob_array = y_prob_array.reshape(-1, 1)
|
|
905
|
+
|
|
906
|
+
# 检查数据长度是否一致
|
|
907
|
+
if len(y_true_array) != len(y_prob_array):
|
|
908
|
+
logger.warning(f" True values and probabilities length mismatch: {len(y_true_array)} vs {len(y_prob_array)}")
|
|
909
|
+
metrics["auc"] = "N/A"
|
|
910
|
+
else:
|
|
911
|
+
# 检查唯一标签数量
|
|
912
|
+
unique_labels = np.unique(y_true_array)
|
|
913
|
+
n_unique = len(unique_labels)
|
|
914
|
+
|
|
915
|
+
if n_unique < 2:
|
|
916
|
+
# 只有一个类别,无法计算AUC
|
|
917
|
+
metrics["auc"] = "N/A"
|
|
918
|
+
elif n_unique == 2:
|
|
919
|
+
# 二分类
|
|
920
|
+
try:
|
|
921
|
+
if y_prob_array.shape[1] >= 2:
|
|
922
|
+
metrics["auc"] = round(roc_auc_score(y_true_array, y_prob_array[:, 1]), 4)
|
|
923
|
+
elif y_prob_array.shape[1] == 1:
|
|
924
|
+
metrics["auc"] = round(roc_auc_score(y_true_array, y_prob_array[:, 0]), 4)
|
|
925
|
+
else:
|
|
926
|
+
metrics["auc"] = "N/A"
|
|
927
|
+
except Exception as e:
|
|
928
|
+
logger.warning(f" Binary classification AUC calculation failed: {str(e)}")
|
|
929
|
+
metrics["auc"] = "N/A"
|
|
930
|
+
else:
|
|
931
|
+
# 多分类:macro OVR;labels 必须与 predict_proba 列顺序一致(通常为 y 中唯一类升序)
|
|
932
|
+
try:
|
|
933
|
+
labels = np.sort(np.unique(y_true_array))
|
|
934
|
+
if y_prob_array.shape[1] != len(labels):
|
|
935
|
+
logger.warning(
|
|
936
|
+
f" Multi-class AUC: y_prob columns ({y_prob_array.shape[1]}) != number of classes ({len(labels)})"
|
|
937
|
+
)
|
|
938
|
+
metrics["auc"] = "N/A"
|
|
939
|
+
else:
|
|
940
|
+
metrics["auc"] = round(
|
|
941
|
+
roc_auc_score(
|
|
942
|
+
y_true_array,
|
|
943
|
+
y_prob_array,
|
|
944
|
+
average="macro",
|
|
945
|
+
multi_class="ovr",
|
|
946
|
+
labels=labels,
|
|
947
|
+
),
|
|
948
|
+
4,
|
|
949
|
+
)
|
|
950
|
+
except Exception as e:
|
|
951
|
+
logger.warning(f" Multi-class AUC calculation failed: {str(e)}")
|
|
952
|
+
metrics["auc"] = "N/A"
|
|
953
|
+
else:
|
|
954
|
+
metrics["auc"] = "N/A"
|
|
955
|
+
if task_type == "classification":
|
|
956
|
+
pass
|
|
957
|
+
except Exception as e:
|
|
958
|
+
logger.warning(f" Failed to calculate AUC: {str(e)}")
|
|
959
|
+
metrics["auc"] = "N/A"
|
|
960
|
+
else:
|
|
961
|
+
# 回归指标:只保留皮尔逊相关系数和p值
|
|
962
|
+
try:
|
|
963
|
+
from scipy.stats import pearsonr
|
|
964
|
+
# 修复:确保y_true和y_pred都是numpy数组,且长度一致
|
|
965
|
+
y_true_array = _ensure_numpy_array(y_true)
|
|
966
|
+
y_pred_array = _ensure_numpy_array(y_pred)
|
|
967
|
+
|
|
968
|
+
if len(y_true_array) == 0 or len(y_pred_array) == 0:
|
|
969
|
+
logger.warning(" True values or predictions are empty, cannot calculate Pearson correlation")
|
|
970
|
+
metrics["pearson_correlation"] = "N/A"
|
|
971
|
+
metrics["pearson_pvalue"] = "N/A"
|
|
972
|
+
elif len(y_true_array) != len(y_pred_array):
|
|
973
|
+
logger.warning(f" True values and predictions length mismatch: {len(y_true_array)} vs {len(y_pred_array)}")
|
|
974
|
+
metrics["pearson_correlation"] = "N/A"
|
|
975
|
+
metrics["pearson_pvalue"] = "N/A"
|
|
976
|
+
else:
|
|
977
|
+
pearson_corr, pearson_pvalue = pearsonr(y_true_array, y_pred_array)
|
|
978
|
+
# 相关系数保留4位小数
|
|
979
|
+
metrics["pearson_correlation"] = round(pearson_corr, 4)
|
|
980
|
+
# p值:常规情况下保留6位小数;当极小时用科学计数法表示,避免被四舍五入为0.0
|
|
981
|
+
if pearson_pvalue < 1e-6:
|
|
982
|
+
metrics["pearson_pvalue"] = float(f"{pearson_pvalue:.2e}")
|
|
983
|
+
else:
|
|
984
|
+
metrics["pearson_pvalue"] = round(pearson_pvalue, 6)
|
|
985
|
+
except ImportError:
|
|
986
|
+
logger.warning(" scipy not installed, cannot calculate Pearson correlation coefficient and p-value")
|
|
987
|
+
metrics["pearson_correlation"] = "N/A"
|
|
988
|
+
metrics["pearson_pvalue"] = "N/A"
|
|
989
|
+
except Exception as e:
|
|
990
|
+
logger.warning(f" Failed to calculate Pearson correlation coefficient: {str(e)}")
|
|
991
|
+
metrics["pearson_correlation"] = "N/A"
|
|
992
|
+
metrics["pearson_pvalue"] = "N/A"
|
|
993
|
+
|
|
994
|
+
# 日志已在evaluate_model内部输出,这里不再重复
|
|
995
|
+
return metrics
|
|
996
|
+
|
|
997
|
+
def calculate_feature_importance(
|
|
998
|
+
model: Any,
|
|
999
|
+
model_type: str,
|
|
1000
|
+
feature_names: List[str],
|
|
1001
|
+
X_train: pd.DataFrame,
|
|
1002
|
+
y_train: pd.Series,
|
|
1003
|
+
task_type: str
|
|
1004
|
+
) -> pd.DataFrame:
|
|
1005
|
+
"""
|
|
1006
|
+
计算特征重要性
|
|
1007
|
+
|
|
1008
|
+
:param model: 训练好的模型
|
|
1009
|
+
:param model_type: 模型类型
|
|
1010
|
+
:param feature_names: 特征名称列表
|
|
1011
|
+
:param X_train: 训练集特征
|
|
1012
|
+
:param y_train: 训练集标签
|
|
1013
|
+
:param task_type: 任务类型(classification/regression)
|
|
1014
|
+
:return: 特征重要性DataFrame
|
|
1015
|
+
"""
|
|
1016
|
+
|
|
1017
|
+
try:
|
|
1018
|
+
# 不同模型的特征重要性获取方式
|
|
1019
|
+
if model_type in ["LightGBM", "XGBoost", "CatBoost"]:
|
|
1020
|
+
# 树模型:使用feature_importances_
|
|
1021
|
+
if hasattr(model, 'feature_importances_'):
|
|
1022
|
+
importances = model.feature_importances_
|
|
1023
|
+
else:
|
|
1024
|
+
# 某些情况下需要先predict
|
|
1025
|
+
model.predict(X_train.iloc[:10])
|
|
1026
|
+
importances = model.feature_importances_
|
|
1027
|
+
|
|
1028
|
+
elif model_type == "RandomForest":
|
|
1029
|
+
# 随机森林:使用feature_importances_
|
|
1030
|
+
importances = model.feature_importances_
|
|
1031
|
+
|
|
1032
|
+
elif model_type == "Logistic":
|
|
1033
|
+
# 线性模型:使用系数的绝对值
|
|
1034
|
+
if hasattr(model, 'coef_'):
|
|
1035
|
+
if task_type == "classification":
|
|
1036
|
+
# 多分类:取所有类别的平均重要性
|
|
1037
|
+
importances = np.abs(model.coef_).mean(axis=0)
|
|
1038
|
+
else:
|
|
1039
|
+
# 回归:直接使用系数绝对值
|
|
1040
|
+
importances = np.abs(model.coef_[0])
|
|
1041
|
+
else:
|
|
1042
|
+
logger.warning(f" {model_type} model does not support feature importance calculation")
|
|
1043
|
+
return pd.DataFrame()
|
|
1044
|
+
|
|
1045
|
+
elif model_type == "SVM":
|
|
1046
|
+
# SVM:使用支持向量的权重(仅适用于线性核)
|
|
1047
|
+
if hasattr(model, 'kernel') and model.kernel == 'linear':
|
|
1048
|
+
if hasattr(model, 'coef_'):
|
|
1049
|
+
importances = np.abs(model.coef_[0])
|
|
1050
|
+
else:
|
|
1051
|
+
# 线性核但没有coef_,尝试使用permutation importance
|
|
1052
|
+
logger.info(" SVM linear kernel: using permutation importance as fallback")
|
|
1053
|
+
try:
|
|
1054
|
+
# 使用较小的样本集以加快计算
|
|
1055
|
+
n_samples = min(1000, len(X_train))
|
|
1056
|
+
X_sample = X_train.sample(n=n_samples, random_state=42) if len(X_train) > n_samples else X_train
|
|
1057
|
+
y_sample = y_train.loc[X_sample.index] if len(X_train) > n_samples else y_train
|
|
1058
|
+
|
|
1059
|
+
scoring = 'roc_auc' if task_type == 'classification' else 'r2'
|
|
1060
|
+
perm_result = permutation_importance(
|
|
1061
|
+
model, X_sample, y_sample,
|
|
1062
|
+
n_repeats=10,
|
|
1063
|
+
random_state=42,
|
|
1064
|
+
scoring=scoring,
|
|
1065
|
+
n_jobs=1
|
|
1066
|
+
)
|
|
1067
|
+
importances = perm_result.importances_mean
|
|
1068
|
+
except Exception as e:
|
|
1069
|
+
logger.warning(f" SVM permutation importance calculation failed: {str(e)}")
|
|
1070
|
+
return pd.DataFrame()
|
|
1071
|
+
else:
|
|
1072
|
+
# 非线性核SVM:使用permutation importance
|
|
1073
|
+
logger.info(" SVM non-linear kernel: using permutation importance")
|
|
1074
|
+
try:
|
|
1075
|
+
# 使用较小的样本集以加快计算
|
|
1076
|
+
n_samples = min(1000, len(X_train))
|
|
1077
|
+
X_sample = X_train.sample(n=n_samples, random_state=42) if len(X_train) > n_samples else X_train
|
|
1078
|
+
y_sample = y_train.loc[X_sample.index] if len(X_train) > n_samples else y_train
|
|
1079
|
+
|
|
1080
|
+
scoring = 'roc_auc' if task_type == 'classification' else 'r2'
|
|
1081
|
+
perm_result = permutation_importance(
|
|
1082
|
+
model, X_sample, y_sample,
|
|
1083
|
+
n_repeats=10,
|
|
1084
|
+
random_state=42,
|
|
1085
|
+
scoring=scoring,
|
|
1086
|
+
n_jobs=1
|
|
1087
|
+
)
|
|
1088
|
+
importances = perm_result.importances_mean
|
|
1089
|
+
except Exception as e:
|
|
1090
|
+
logger.warning(f" SVM permutation importance calculation failed: {str(e)}")
|
|
1091
|
+
return pd.DataFrame()
|
|
1092
|
+
|
|
1093
|
+
else:
|
|
1094
|
+
logger.warning(f" {model_type} model does not support feature importance calculation")
|
|
1095
|
+
return pd.DataFrame()
|
|
1096
|
+
|
|
1097
|
+
# 获取原始值(带正负号)用于计算正负效应
|
|
1098
|
+
original_values = None
|
|
1099
|
+
if model_type == "Logistic":
|
|
1100
|
+
# 线性模型:使用原始系数
|
|
1101
|
+
if hasattr(model, 'coef_'):
|
|
1102
|
+
if task_type == "classification":
|
|
1103
|
+
original_values = model.coef_.mean(axis=0)
|
|
1104
|
+
else:
|
|
1105
|
+
original_values = model.coef_[0]
|
|
1106
|
+
elif model_type == "SVM":
|
|
1107
|
+
# SVM:如果是线性核且有coef_,使用原始系数;否则使用permutation importance(都是正值)
|
|
1108
|
+
if hasattr(model, 'kernel') and model.kernel == 'linear' and hasattr(model, 'coef_'):
|
|
1109
|
+
if task_type == "classification":
|
|
1110
|
+
original_values = model.coef_.mean(axis=0) if len(model.coef_.shape) > 1 else model.coef_[0]
|
|
1111
|
+
else:
|
|
1112
|
+
original_values = model.coef_[0]
|
|
1113
|
+
else:
|
|
1114
|
+
# 非线性核或使用permutation importance:都是正值,正负效应设为1
|
|
1115
|
+
original_values = importances
|
|
1116
|
+
elif model_type in ["LightGBM", "XGBoost", "CatBoost", "RandomForest"]:
|
|
1117
|
+
# 树模型:特征重要性通常是正数,正负效应设为1(正)
|
|
1118
|
+
original_values = importances # 树模型的重要性都是正数
|
|
1119
|
+
|
|
1120
|
+
# 如果无法获取原始值,使用绝对值(正负效应设为1)
|
|
1121
|
+
if original_values is None:
|
|
1122
|
+
original_values = importances
|
|
1123
|
+
|
|
1124
|
+
# 计算绝对值
|
|
1125
|
+
abs_values = np.abs(original_values)
|
|
1126
|
+
|
|
1127
|
+
# 确定正负效应(1表示正效应,-1表示负效应)
|
|
1128
|
+
sign_effect = np.sign(original_values)
|
|
1129
|
+
# 将0转换为1(表示正效应)
|
|
1130
|
+
sign_effect = np.where(sign_effect == 0, 1, sign_effect)
|
|
1131
|
+
|
|
1132
|
+
# 创建特征重要性DataFrame(三列:特征名、绝对值、正负效应)
|
|
1133
|
+
importance_df = pd.DataFrame({
|
|
1134
|
+
'feature': feature_names,
|
|
1135
|
+
'importance_abs': abs_values,
|
|
1136
|
+
'effect': sign_effect.astype(int)
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
# 按绝对值降序排序
|
|
1140
|
+
importance_df = importance_df.sort_values('importance_abs', ascending=False)
|
|
1141
|
+
importance_df = importance_df.reset_index(drop=True)
|
|
1142
|
+
|
|
1143
|
+
logger.info(" Feature importance calculation completed")
|
|
1144
|
+
return importance_df
|
|
1145
|
+
|
|
1146
|
+
except Exception as e:
|
|
1147
|
+
logger.error(f" Feature importance calculation failed: {str(e)}")
|
|
1148
|
+
return pd.DataFrame()
|
|
1149
|
+
|
|
1150
|
+
def calculate_shap_values(
|
|
1151
|
+
model: Any,
|
|
1152
|
+
model_type: str,
|
|
1153
|
+
feature_names: List[str],
|
|
1154
|
+
X_train: pd.DataFrame,
|
|
1155
|
+
y_train: pd.Series,
|
|
1156
|
+
task_type: str,
|
|
1157
|
+
# 参与SHAP解释的样本数(默认全量;可用于控制输出/耗时)
|
|
1158
|
+
shap_sample_size: Optional[int] = None,
|
|
1159
|
+
) -> pd.DataFrame:
|
|
1160
|
+
"""
|
|
1161
|
+
计算SHAP值(格式与feature_importance相同)
|
|
1162
|
+
|
|
1163
|
+
:param model: 训练好的模型
|
|
1164
|
+
:param model_type: 模型类型
|
|
1165
|
+
:param feature_names: 特征名称列表
|
|
1166
|
+
:param X_train: 训练集特征
|
|
1167
|
+
:param y_train: 训练集标签
|
|
1168
|
+
:param task_type: 任务类型(classification/regression)
|
|
1169
|
+
:return: SHAP值DataFrame(三列:feature, shap_abs, effect)
|
|
1170
|
+
"""
|
|
1171
|
+
|
|
1172
|
+
if not SHAP_AVAILABLE:
|
|
1173
|
+
logger.warning(" SHAP library not installed, cannot calculate SHAP values. Please run: pip install shap")
|
|
1174
|
+
return pd.DataFrame()
|
|
1175
|
+
|
|
1176
|
+
try:
|
|
1177
|
+
shap_t0 = time.time()
|
|
1178
|
+
# 使用全部数据计算SHAP值
|
|
1179
|
+
X_shap = X_train
|
|
1180
|
+
y_shap = y_train
|
|
1181
|
+
X_input = X_shap[feature_names] if feature_names else X_shap
|
|
1182
|
+
|
|
1183
|
+
# 默认:不指定 shap_sample_size 就用全量;SVM/Kernel 路径会再做上限控制
|
|
1184
|
+
if shap_sample_size is not None and len(X_input) > shap_sample_size:
|
|
1185
|
+
X_input = X_input.sample(n=int(shap_sample_size), random_state=42)
|
|
1186
|
+
y_shap = y_shap.loc[X_input.index]
|
|
1187
|
+
|
|
1188
|
+
# 根据模型类型选择合适的SHAP解释器
|
|
1189
|
+
if model_type in ["LightGBM", "XGBoost", "CatBoost", "RandomForest"]:
|
|
1190
|
+
# 树模型:使用TreeExplainer
|
|
1191
|
+
explainer = shap.TreeExplainer(model)
|
|
1192
|
+
shap_values = explainer.shap_values(X_shap[feature_names] if feature_names else X_shap)
|
|
1193
|
+
elif model_type == "Logistic":
|
|
1194
|
+
# 线性逻辑回归:使用LinearExplainer
|
|
1195
|
+
explainer = shap.LinearExplainer(model, X_shap[feature_names] if feature_names else X_shap)
|
|
1196
|
+
shap_values = explainer.shap_values(X_shap[feature_names] if feature_names else X_shap)
|
|
1197
|
+
elif model_type == "SVM":
|
|
1198
|
+
# ======================== SVM: 固定使用线性代理模型(近似) ========================
|
|
1199
|
+
# 说明:
|
|
1200
|
+
# - 训练模型默认是 rbf 核(见 init_model),KernelExplainer 计算复杂度极高且在 1k+ 特征下非常慢
|
|
1201
|
+
# - 这里固定用 LinearSVR / LinearSVC 拟合一个线性代理模型,再用 LinearExplainer 计算SHAP
|
|
1202
|
+
from sklearn.svm import LinearSVR, LinearSVC
|
|
1203
|
+
|
|
1204
|
+
# 控制代理模型训练数据量,避免在超大样本时耗时(保持通用性)
|
|
1205
|
+
X_sur = X_input
|
|
1206
|
+
if len(X_sur) > 5000:
|
|
1207
|
+
X_sur = X_sur.sample(n=5000, random_state=42)
|
|
1208
|
+
y_sur = y_shap.loc[X_sur.index]
|
|
1209
|
+
|
|
1210
|
+
logger.info(
|
|
1211
|
+
f" [SHAP][SVM] Fixed mode: linear surrogate + LinearExplainer "
|
|
1212
|
+
f"(surrogate_samples={len(X_sur):,}, features={X_sur.shape[1]:,}, task={task_type})"
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
if task_type == "classification":
|
|
1216
|
+
surrogate = LinearSVC(C=1.0, dual=True, max_iter=5000)
|
|
1217
|
+
else:
|
|
1218
|
+
surrogate = LinearSVR(C=1.0, dual=True, max_iter=5000)
|
|
1219
|
+
|
|
1220
|
+
surrogate.fit(X_sur, y_sur)
|
|
1221
|
+
explainer = shap.LinearExplainer(surrogate, X_sur, feature_perturbation="interventional")
|
|
1222
|
+
shap_values = explainer.shap_values(X_input)
|
|
1223
|
+
else:
|
|
1224
|
+
# 其他模型:使用KernelExplainer(较慢)
|
|
1225
|
+
logger.warning(f" {model_type} model uses KernelExplainer for SHAP values, may be slow")
|
|
1226
|
+
# 使用少量样本作为背景数据
|
|
1227
|
+
background_size = min(100, len(X_shap))
|
|
1228
|
+
background = X_shap[feature_names].sample(n=background_size, random_state=42) if feature_names else X_shap.sample(n=background_size, random_state=42)
|
|
1229
|
+
explainer = shap.KernelExplainer(
|
|
1230
|
+
model.predict if task_type == "regression" else lambda x: model.predict_proba(x)[:, 1] if hasattr(model, 'predict_proba') else model.predict(x),
|
|
1231
|
+
background
|
|
1232
|
+
)
|
|
1233
|
+
shap_values = explainer.shap_values(X_shap[feature_names] if feature_names else X_shap)
|
|
1234
|
+
|
|
1235
|
+
# 处理多分类任务的SHAP值(取平均)
|
|
1236
|
+
if isinstance(shap_values, list):
|
|
1237
|
+
# 多分类:对每个类别的SHAP值取平均
|
|
1238
|
+
shap_values = np.mean([np.abs(sv) for sv in shap_values], axis=0)
|
|
1239
|
+
elif len(shap_values.shape) > 2:
|
|
1240
|
+
# 多维数组:取平均
|
|
1241
|
+
shap_values = np.mean(shap_values, axis=0)
|
|
1242
|
+
|
|
1243
|
+
# 确保是2D数组
|
|
1244
|
+
if len(shap_values.shape) == 1:
|
|
1245
|
+
shap_values = shap_values.reshape(1, -1)
|
|
1246
|
+
|
|
1247
|
+
# 计算每个特征的平均SHAP值(绝对值)
|
|
1248
|
+
mean_shap_abs = np.abs(shap_values).mean(axis=0)
|
|
1249
|
+
|
|
1250
|
+
# 计算每个特征的平均SHAP值(带符号,用于确定正负效应)
|
|
1251
|
+
mean_shap_signed = shap_values.mean(axis=0)
|
|
1252
|
+
|
|
1253
|
+
# 确定正负效应(1表示正效应,-1表示负效应)
|
|
1254
|
+
sign_effect = np.sign(mean_shap_signed)
|
|
1255
|
+
# 将0转换为1(表示正效应)
|
|
1256
|
+
sign_effect = np.where(sign_effect == 0, 1, sign_effect)
|
|
1257
|
+
|
|
1258
|
+
# 创建SHAP值DataFrame(三列:特征名、绝对值、正负效应)
|
|
1259
|
+
# 特征名统一使用当前训练特征矩阵的列名(X_train.columns),
|
|
1260
|
+
# 而不是外部传入的原始基因型 SNP ID 列表,保证与整体特征处理逻辑一致。
|
|
1261
|
+
# 同时增加鲁棒性检查,确保所有数组长度一致,避免
|
|
1262
|
+
# "All arrays must be of the same length" 错误。
|
|
1263
|
+
feature_list = X_train.columns.tolist()
|
|
1264
|
+
n_features_from_shap = len(mean_shap_abs)
|
|
1265
|
+
n_features_from_names = len(feature_list)
|
|
1266
|
+
n_features_from_sign = len(sign_effect)
|
|
1267
|
+
|
|
1268
|
+
# 取三者中的最小长度进行对齐
|
|
1269
|
+
min_len = min(n_features_from_shap, n_features_from_names, n_features_from_sign)
|
|
1270
|
+
if not (n_features_from_shap == n_features_from_names == n_features_from_sign):
|
|
1271
|
+
logger.warning(
|
|
1272
|
+
" SHAP feature-length mismatch; aligning by minimum length: "
|
|
1273
|
+
f"shap={n_features_from_shap}, names={n_features_from_names}, sign={n_features_from_sign}"
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
feature_list = feature_list[:min_len]
|
|
1277
|
+
mean_shap_abs = mean_shap_abs[:min_len]
|
|
1278
|
+
sign_effect = sign_effect[:min_len]
|
|
1279
|
+
|
|
1280
|
+
shap_df = pd.DataFrame({
|
|
1281
|
+
'feature': feature_list,
|
|
1282
|
+
'shap_abs': mean_shap_abs,
|
|
1283
|
+
'effect': sign_effect.astype(int)
|
|
1284
|
+
})
|
|
1285
|
+
|
|
1286
|
+
# 按绝对值降序排序
|
|
1287
|
+
shap_df = shap_df.sort_values('shap_abs', ascending=False)
|
|
1288
|
+
shap_df = shap_df.reset_index(drop=True)
|
|
1289
|
+
|
|
1290
|
+
logger.info(
|
|
1291
|
+
f" SHAP values calculation completed "
|
|
1292
|
+
f"(samples_used={len(X_input):,}, elapsed={time.time()-shap_t0:.2f}s)"
|
|
1293
|
+
)
|
|
1294
|
+
return shap_df
|
|
1295
|
+
|
|
1296
|
+
except Exception as e:
|
|
1297
|
+
logger.error(f" SHAP values calculation failed: {str(e)}")
|
|
1298
|
+
import traceback
|
|
1299
|
+
return pd.DataFrame()
|
|
1300
|
+
|
|
1301
|
+
def _ensure_numpy_array(data: Any) -> np.ndarray:
|
|
1302
|
+
"""
|
|
1303
|
+
确保数据是numpy数组(提取公共代码)
|
|
1304
|
+
|
|
1305
|
+
:param data: 输入数据(可能是pd.Series、list或np.ndarray)
|
|
1306
|
+
:return: numpy数组
|
|
1307
|
+
"""
|
|
1308
|
+
if isinstance(data, np.ndarray):
|
|
1309
|
+
return data
|
|
1310
|
+
elif isinstance(data, pd.Series):
|
|
1311
|
+
return data.values
|
|
1312
|
+
else:
|
|
1313
|
+
return np.array(data)
|
|
1314
|
+
|
|
1315
|
+
def filter_phenotype_columns(columns: List[str]) -> List[str]:
|
|
1316
|
+
"""
|
|
1317
|
+
过滤掉包含phenotype的列名(提取公共代码)
|
|
1318
|
+
|
|
1319
|
+
:param columns: 列名列表
|
|
1320
|
+
:return: 过滤后的列名列表
|
|
1321
|
+
"""
|
|
1322
|
+
return [col for col in columns if 'phenotype' not in str(col).lower()]
|
|
1323
|
+
|
|
1324
|
+
def filter_phenotype_from_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
|
1325
|
+
"""
|
|
1326
|
+
从DataFrame中过滤掉包含phenotype的列
|
|
1327
|
+
|
|
1328
|
+
:param df: 输入DataFrame
|
|
1329
|
+
:return: 过滤后的DataFrame
|
|
1330
|
+
"""
|
|
1331
|
+
phenotype_cols = [col for col in df.columns if 'phenotype' in str(col).lower()]
|
|
1332
|
+
# 兜底清理:剔除 preprocess/拼接过程中引入的 index/index.N 伪特征列
|
|
1333
|
+
# 典型来源:多染色体拼接时重复列名,read_csv 会自动改名为 index.1/index.2/...
|
|
1334
|
+
import re
|
|
1335
|
+
index_artifact_cols = [
|
|
1336
|
+
col for col in df.columns
|
|
1337
|
+
if re.match(r'^index(\.\d+)?$', str(col), flags=re.IGNORECASE)
|
|
1338
|
+
]
|
|
1339
|
+
|
|
1340
|
+
drop_cols = []
|
|
1341
|
+
if phenotype_cols:
|
|
1342
|
+
drop_cols.extend(phenotype_cols)
|
|
1343
|
+
if index_artifact_cols:
|
|
1344
|
+
drop_cols.extend(index_artifact_cols)
|
|
1345
|
+
|
|
1346
|
+
if drop_cols:
|
|
1347
|
+
# 去重保持顺序
|
|
1348
|
+
seen = set()
|
|
1349
|
+
drop_cols_unique = []
|
|
1350
|
+
for c in drop_cols:
|
|
1351
|
+
if c not in seen:
|
|
1352
|
+
seen.add(c)
|
|
1353
|
+
drop_cols_unique.append(c)
|
|
1354
|
+
if index_artifact_cols:
|
|
1355
|
+
logger.warning(
|
|
1356
|
+
f"Detected and removed index artifact columns from features: {index_artifact_cols[:5]}"
|
|
1357
|
+
f"{'...' if len(index_artifact_cols) > 5 else ''}"
|
|
1358
|
+
)
|
|
1359
|
+
return df.drop(columns=drop_cols_unique, errors='ignore')
|
|
1360
|
+
return df
|
|
1361
|
+
|
|
1362
|
+
def save_training_results(
|
|
1363
|
+
model: Any,
|
|
1364
|
+
metrics: Dict,
|
|
1365
|
+
selected_snps: List[str],
|
|
1366
|
+
output_dir: str,
|
|
1367
|
+
model_type: str,
|
|
1368
|
+
task_type: str,
|
|
1369
|
+
feature_importance_df: Optional[pd.DataFrame] = None,
|
|
1370
|
+
shap_df: Optional[pd.DataFrame] = None,
|
|
1371
|
+
y_test: Optional[pd.Series] = None,
|
|
1372
|
+
y_pred: Optional[np.ndarray] = None,
|
|
1373
|
+
y_prob: Optional[np.ndarray] = None,
|
|
1374
|
+
cv_results: Optional[Dict] = None
|
|
1375
|
+
) -> None:
|
|
1376
|
+
"""
|
|
1377
|
+
保存训练结果(模型文件、评估指标、筛选的SNP列表、特征重要性、SHAP值)
|
|
1378
|
+
|
|
1379
|
+
:param model: 训练好的模型
|
|
1380
|
+
:param metrics: 评估指标
|
|
1381
|
+
:param selected_snps: 筛选的SNP列表
|
|
1382
|
+
:param output_dir: 输出目录
|
|
1383
|
+
:param model_type: 模型类型
|
|
1384
|
+
:param task_type: 任务类型
|
|
1385
|
+
:param feature_importance_df: 特征重要性DataFrame
|
|
1386
|
+
:param shap_df: SHAP值DataFrame
|
|
1387
|
+
:param y_test: 测试集真实值(用于绘制性能曲线)
|
|
1388
|
+
:param y_pred: 测试集预测值(用于绘制性能曲线)
|
|
1389
|
+
:param y_prob: 测试集预测概率(分类任务需要,用于绘制性能曲线)
|
|
1390
|
+
:param cv_results: 交叉验证结果字典(用于绘制交叉验证曲线)
|
|
1391
|
+
"""
|
|
1392
|
+
# 创建模型专属目录
|
|
1393
|
+
model_dir = Path(output_dir) / model_type
|
|
1394
|
+
model_dir.mkdir(parents=True, exist_ok=True)
|
|
1395
|
+
|
|
1396
|
+
# ======================== 输出命名规范化(standardized) ========================
|
|
1397
|
+
# 统一输出前缀:{output_dir}/{model_type}/{model_type}
|
|
1398
|
+
# 所有产物文件都以该前缀 + 固定后缀命名,避免目录内出现“裸文件名”导致覆盖与歧义
|
|
1399
|
+
output_prefix = model_dir / model_type
|
|
1400
|
+
|
|
1401
|
+
# 1. 保存模型文件(规范:仍保持“模型名称”命名)
|
|
1402
|
+
# - 文件名固定为:{model_type}_model.pkl
|
|
1403
|
+
# - 避免把更多前缀信息塞进模型文件名,便于用户直观识别与手动指定
|
|
1404
|
+
model_file = model_dir / f"{model_type}_model.pkl"
|
|
1405
|
+
import joblib
|
|
1406
|
+
joblib.dump(model, model_file)
|
|
1407
|
+
|
|
1408
|
+
# 2. 保存评估指标
|
|
1409
|
+
metrics_file = Path(f"{output_prefix}_metrics.json")
|
|
1410
|
+
with open(metrics_file, 'w') as f:
|
|
1411
|
+
json.dump({
|
|
1412
|
+
"model_type": model_type,
|
|
1413
|
+
"task_type": task_type,
|
|
1414
|
+
"metrics": metrics,
|
|
1415
|
+
"training_time": time.strftime("%Y-%m-%d %H:%M:%S")
|
|
1416
|
+
}, f, indent=2)
|
|
1417
|
+
|
|
1418
|
+
# 2.5 保存训练阶段使用的特征列表(用于预测阶段特征对齐)
|
|
1419
|
+
# 说明:
|
|
1420
|
+
# - selected_snps 已经是去除 phenotype 后的所有特征列名(且经过 clean_feature_names 清洗)
|
|
1421
|
+
# - 预测阶段将以此列表为基准:
|
|
1422
|
+
# * 训练有、预测也有 → 正常使用;
|
|
1423
|
+
# * 训练有、预测没有 → 在预测矩阵中补一列,整列填0;
|
|
1424
|
+
# * 训练没有、预测有 → 在预测阶段丢弃该列。
|
|
1425
|
+
try:
|
|
1426
|
+
features_file = Path(f"{output_prefix}_training_features.json")
|
|
1427
|
+
with open(features_file, "w") as f:
|
|
1428
|
+
json.dump(
|
|
1429
|
+
{
|
|
1430
|
+
"model_type": model_type,
|
|
1431
|
+
"task_type": task_type,
|
|
1432
|
+
"feature_names": list(selected_snps) if selected_snps is not None else [],
|
|
1433
|
+
"saved_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
1434
|
+
},
|
|
1435
|
+
f,
|
|
1436
|
+
indent=2,
|
|
1437
|
+
)
|
|
1438
|
+
except Exception as e:
|
|
1439
|
+
# 保存失败不影响训练流程,但会在预测阶段失去自动对齐能力
|
|
1440
|
+
logger.warning(f" Failed to save training feature names (ignored): {e}")
|
|
1441
|
+
|
|
1442
|
+
# 3. 保存特征重要性(可选)
|
|
1443
|
+
if feature_importance_df is not None and not feature_importance_df.empty:
|
|
1444
|
+
# 排除phenotype列
|
|
1445
|
+
filtered_importance_df = feature_importance_df[
|
|
1446
|
+
feature_importance_df['feature'].astype(str).apply(lambda x: 'phenotype' not in x.lower())
|
|
1447
|
+
]
|
|
1448
|
+
importance_file = Path(f"{output_prefix}_feature_importance.txt")
|
|
1449
|
+
filtered_importance_df.to_csv(importance_file, sep='\t', index=False)
|
|
1450
|
+
|
|
1451
|
+
# 4.5. 保存SHAP值(可选,格式与feature_importance相同)
|
|
1452
|
+
if shap_df is not None and not shap_df.empty:
|
|
1453
|
+
# 排除phenotype列
|
|
1454
|
+
filtered_shap_df = shap_df[
|
|
1455
|
+
shap_df['feature'].astype(str).apply(lambda x: 'phenotype' not in x.lower())
|
|
1456
|
+
]
|
|
1457
|
+
# 重命名列以匹配feature_importance格式(feature, importance_abs, effect)
|
|
1458
|
+
# 但为了区分,我们保持shap_abs列名,或者重命名为importance_abs以兼容可视化
|
|
1459
|
+
shap_file = Path(f"{output_prefix}_shap_values.txt")
|
|
1460
|
+
# 为了兼容可视化模块,将shap_abs重命名为importance_abs
|
|
1461
|
+
shap_output_df = filtered_shap_df.copy()
|
|
1462
|
+
shap_output_df = shap_output_df.rename(columns={'shap_abs': 'importance_abs'})
|
|
1463
|
+
shap_output_df.to_csv(shap_file, sep='\t', index=False)
|
|
1464
|
+
|
|
1465
|
+
# 5. 保存所有绘图数据到一个统一文件(用于visualization模块)
|
|
1466
|
+
if y_test is not None and y_pred is not None:
|
|
1467
|
+
import pickle
|
|
1468
|
+
plotting_data_file = Path(f"{output_prefix}_plotting_data.npz")
|
|
1469
|
+
save_dict = {
|
|
1470
|
+
# 预测数据(numpy数组)
|
|
1471
|
+
'y_test': y_test.values if isinstance(y_test, pd.Series) else y_test,
|
|
1472
|
+
'y_pred': y_pred,
|
|
1473
|
+
# 元数据(字符串)
|
|
1474
|
+
'model_type': np.array([model_type], dtype=object),
|
|
1475
|
+
'task_type': np.array([task_type], dtype=object),
|
|
1476
|
+
# 兼容字段名(fixed):publication_quality 固定由内部常量控制
|
|
1477
|
+
'publication_quality': np.array([PUB_QUALITY_MODE], dtype=bool)
|
|
1478
|
+
}
|
|
1479
|
+
# 预测概率(如果存在)
|
|
1480
|
+
if y_prob is not None:
|
|
1481
|
+
save_dict['y_prob'] = y_prob
|
|
1482
|
+
# 交叉验证结果(使用pickle序列化字典)
|
|
1483
|
+
if cv_results is not None:
|
|
1484
|
+
save_dict['cv_results'] = np.array([pickle.dumps(cv_results)], dtype=object)
|
|
1485
|
+
|
|
1486
|
+
np.savez_compressed(plotting_data_file, **save_dict)
|
|
1487
|
+
|
|
1488
|
+
logger.info(f" Results saved successfully: {model_dir}")
|
|
1489
|
+
|
|
1490
|
+
# ======================== 5. 核心训练函数 ========================
|
|
1491
|
+
|
|
1492
|
+
def run_single_model(
|
|
1493
|
+
input_path: str,
|
|
1494
|
+
model_type: str,
|
|
1495
|
+
output_dir: str,
|
|
1496
|
+
task_type: Optional[str] = None,
|
|
1497
|
+
n_folds: int = 5,
|
|
1498
|
+
random_state: int = 42,
|
|
1499
|
+
# 特征重要性计算参数(可选)
|
|
1500
|
+
calculate_feature_importance: bool = False,
|
|
1501
|
+
# 预加载的训练数据(可选,用于train-all中避免重复读取文件)
|
|
1502
|
+
preloaded_data: Optional[Dict[str, Any]] = None,
|
|
1503
|
+
# 训练时允许使用的最大 CPU 核心数(用于限制多线程库的并行程度)
|
|
1504
|
+
cpu_cores: Optional[int] = None,
|
|
1505
|
+
) -> int:
|
|
1506
|
+
"""
|
|
1507
|
+
单模型训练主函数
|
|
1508
|
+
说明:
|
|
1509
|
+
- GWAS / LD / GWAS+LD 综合过滤逻辑已经前移到 preprocess 模块
|
|
1510
|
+
- 训练阶段使用 preprocess 已筛选的特征,不再执行额外的特征筛选
|
|
1511
|
+
- 本函数支持两种数据获取方式:
|
|
1512
|
+
1) 通过 input_path 解析并读取训练文件(默认行为)
|
|
1513
|
+
2) 通过 preloaded_data 直接使用预加载的训练数据(用于 train-all,避免重复读盘)
|
|
1514
|
+
- 交叉验证流程调整为:先在全数据上进行一次随机搜索得到最佳超参数,再使用固定超参数做 n_folds 折交叉验证评估,不在每一折里重复做超参搜索
|
|
1515
|
+
"""
|
|
1516
|
+
# 强制要求使用 preprocess 生成的 metadata.json 作为训练入口,避免 txt 直读导致样本/任务类型/临时目录等信息不完整
|
|
1517
|
+
if not str(input_path).endswith("_metadata.json"):
|
|
1518
|
+
raise ValueError(
|
|
1519
|
+
"Training input must be the preprocess-generated metadata file (*_metadata.json)."
|
|
1520
|
+
f"Current input: {input_path}"
|
|
1521
|
+
)
|
|
1522
|
+
# 保存对calculate_feature_importance函数的引用,避免与参数名冲突
|
|
1523
|
+
_calculate_feature_importance_func = globals()['calculate_feature_importance']
|
|
1524
|
+
|
|
1525
|
+
# 限制本进程使用的线程数,避免与其它 model 进程叠加超配
|
|
1526
|
+
if cpu_cores is not None:
|
|
1527
|
+
cpu_cores_value = max(1, int(cpu_cores))
|
|
1528
|
+
for var in [
|
|
1529
|
+
"OMP_NUM_THREADS",
|
|
1530
|
+
"MKL_NUM_THREADS",
|
|
1531
|
+
"OPENBLAS_NUM_THREADS",
|
|
1532
|
+
"NUMEXPR_NUM_THREADS",
|
|
1533
|
+
"VECLIB_MAXIMUM_THREADS",
|
|
1534
|
+
"BLIS_NUM_THREADS",
|
|
1535
|
+
]:
|
|
1536
|
+
os.environ[var] = str(cpu_cores_value)
|
|
1537
|
+
|
|
1538
|
+
start_time = time.time()
|
|
1539
|
+
output_dir_path = Path(output_dir)
|
|
1540
|
+
output_dir_path.mkdir(parents=True, exist_ok=True)
|
|
1541
|
+
|
|
1542
|
+
# ======================== 临时目录规范化(standardized) ========================
|
|
1543
|
+
# 临时文件放在基础输出目录下的 tmp/train 子目录中(与阶段目录同级)
|
|
1544
|
+
# 每个阶段在 tmp 下创建自己的子目录,避免不同阶段的临时文件互相干扰
|
|
1545
|
+
# 例如:如果 output_dir 是 test/train,则 tmp_root 是 test/tmp/train
|
|
1546
|
+
base_output_dir = output_dir_path.parent
|
|
1547
|
+
# 重要:tmp 必须按“模型+进程”隔离。
|
|
1548
|
+
# train-all 会并行调用 run_single_model;若共享 tmp 或在子进程里删除 base_output_dir/tmp,
|
|
1549
|
+
# 会产生竞态:进程互相删对方临时文件,导致随机失败/结果不完整。
|
|
1550
|
+
tmp_root = base_output_dir / "tmp" / "train" / model_type
|
|
1551
|
+
tmp_root.mkdir(parents=True, exist_ok=True)
|
|
1552
|
+
|
|
1553
|
+
# 使用全局临时文件管理器注册临时目录/文件
|
|
1554
|
+
def register_dir(p: Path) -> Path:
|
|
1555
|
+
return _temp_file_manager.register_dir(p)
|
|
1556
|
+
|
|
1557
|
+
def register_file(p: Path) -> Path:
|
|
1558
|
+
return _temp_file_manager.register_file(p)
|
|
1559
|
+
|
|
1560
|
+
# 使用进程级目录,避免同一模型在不同进程里冲突
|
|
1561
|
+
run_tmp_dir = register_dir(tmp_root / f"pid_{os.getpid()}")
|
|
1562
|
+
|
|
1563
|
+
# 预处理阶段的临时目录(从元数据读取,不清理,preprocess模块执行完毕不删除tmp目录)
|
|
1564
|
+
preprocess_tmp_dir = None
|
|
1565
|
+
|
|
1566
|
+
try:
|
|
1567
|
+
# Step 1: 获取训练数据和任务类型
|
|
1568
|
+
if preloaded_data is not None:
|
|
1569
|
+
# 来自 train-all 预加载的数据
|
|
1570
|
+
X = preloaded_data["X"]
|
|
1571
|
+
y = preloaded_data["y"]
|
|
1572
|
+
# 预处理阶段的临时目录(如果存在)
|
|
1573
|
+
if preloaded_data.get("preprocess_tmp_dir"):
|
|
1574
|
+
preprocess_tmp_dir = Path(preloaded_data["preprocess_tmp_dir"]).absolute()
|
|
1575
|
+
_temp_file_manager.register_preprocess_tmp_dir(preprocess_tmp_dir)
|
|
1576
|
+
# 若未显式指定 task_type,优先使用预加载中的任务类型
|
|
1577
|
+
def infer_task_type_from_y(series: pd.Series) -> str:
|
|
1578
|
+
values = series.values
|
|
1579
|
+
unique_vals = np.unique(values)
|
|
1580
|
+
is_all_int = np.all(values == np.round(values))
|
|
1581
|
+
if len(unique_vals) <= 10 and is_all_int:
|
|
1582
|
+
return "classification"
|
|
1583
|
+
return "regression"
|
|
1584
|
+
|
|
1585
|
+
if task_type is None:
|
|
1586
|
+
task_type = preloaded_data.get("task_type") or infer_task_type_from_y(y)
|
|
1587
|
+
logger.info(f"Task type: {task_type}")
|
|
1588
|
+
else:
|
|
1589
|
+
# 通过 input_path 解析元数据并读取训练文件
|
|
1590
|
+
input_info = parse_train_input_path(input_path)
|
|
1591
|
+
train_file = input_info["train_file"]
|
|
1592
|
+
valid_samples = input_info["metadata"]["valid_samples"] if input_info["metadata"] else None
|
|
1593
|
+
|
|
1594
|
+
# 如果元数据里带有 preprocess_tmp_dir(tmp_p目录),则记录(但不清理,preprocess模块执行完毕不删除)
|
|
1595
|
+
if input_info["preprocess_tmp_dir"]:
|
|
1596
|
+
preprocess_tmp_dir = Path(input_info["preprocess_tmp_dir"]).absolute()
|
|
1597
|
+
_temp_file_manager.register_preprocess_tmp_dir(preprocess_tmp_dir)
|
|
1598
|
+
|
|
1599
|
+
# 加载训练数据
|
|
1600
|
+
X, y, snp_name_mapping = load_training_data(train_file, valid_samples)
|
|
1601
|
+
|
|
1602
|
+
# 若未提供task_type,则基于表型推断(规则与preprocess一致)
|
|
1603
|
+
def infer_task_type_from_y(series: pd.Series) -> str:
|
|
1604
|
+
values = series.values
|
|
1605
|
+
unique_vals = np.unique(values)
|
|
1606
|
+
is_all_int = np.all(values == np.round(values))
|
|
1607
|
+
if len(unique_vals) <= 10 and is_all_int:
|
|
1608
|
+
return "classification"
|
|
1609
|
+
return "regression"
|
|
1610
|
+
|
|
1611
|
+
if task_type is None:
|
|
1612
|
+
task_type = infer_task_type_from_y(y)
|
|
1613
|
+
logger.info(f"Task type: {task_type}")
|
|
1614
|
+
|
|
1615
|
+
logger.info(f"Dataset: {X.shape[0]:,} samples, {X.shape[1]:,} features")
|
|
1616
|
+
|
|
1617
|
+
# 使用全部特征(preprocess 已完成所需的特征筛选)
|
|
1618
|
+
X_filtered = X
|
|
1619
|
+
|
|
1620
|
+
# Step 2: 先在全数据上进行一次随机搜索,确定最佳超参数(内部自带交叉验证)
|
|
1621
|
+
logger.info("Starting hyperparameter search (RandomizedSearchCV)...")
|
|
1622
|
+
param_grid = get_param_grid(model_type, task_type)
|
|
1623
|
+
# 使用与外层相同的折数进行搜索,避免过度计算
|
|
1624
|
+
best_model = perform_grid_search(
|
|
1625
|
+
model_type=model_type,
|
|
1626
|
+
task_type=task_type,
|
|
1627
|
+
X_train=X_filtered,
|
|
1628
|
+
y_train=y,
|
|
1629
|
+
param_grid=param_grid,
|
|
1630
|
+
n_iter=30, # 适度的搜索次数
|
|
1631
|
+
cv=n_folds, # 与外层评估折数保持一致
|
|
1632
|
+
random_state=random_state,
|
|
1633
|
+
cpu_cores=cpu_cores,
|
|
1634
|
+
)
|
|
1635
|
+
best_params = best_model.get_params()
|
|
1636
|
+
logger.info("Hyperparameter search completed, starting cross-validation with fixed best parameters")
|
|
1637
|
+
|
|
1638
|
+
# Step 3: 使用固定超参数做 n_folds 折交叉验证评估(不再在每一折内部重复做随机搜索)
|
|
1639
|
+
if task_type == "classification":
|
|
1640
|
+
kf = StratifiedKFold(n_splits=n_folds, shuffle=True, random_state=random_state)
|
|
1641
|
+
splits = list(kf.split(X_filtered, y))
|
|
1642
|
+
else:
|
|
1643
|
+
kf = KFold(n_splits=n_folds, shuffle=True, random_state=random_state)
|
|
1644
|
+
splits = list(kf.split(X_filtered, y))
|
|
1645
|
+
|
|
1646
|
+
logger.info(f"Starting {n_folds}-fold cross-validation with fixed hyperparameters")
|
|
1647
|
+
|
|
1648
|
+
cv_fold_metrics = []
|
|
1649
|
+
all_y_test = []
|
|
1650
|
+
all_y_pred = []
|
|
1651
|
+
all_y_prob = []
|
|
1652
|
+
|
|
1653
|
+
for fold_idx, (train_idx, val_idx) in enumerate(splits, 1):
|
|
1654
|
+
train_idx = np.asarray(train_idx)
|
|
1655
|
+
val_idx = np.asarray(val_idx)
|
|
1656
|
+
|
|
1657
|
+
X_train_fold = X_filtered.iloc[train_idx].copy()
|
|
1658
|
+
X_val_fold = X_filtered.iloc[val_idx].copy()
|
|
1659
|
+
y_train_fold = y.iloc[train_idx].copy()
|
|
1660
|
+
y_val_fold = y.iloc[val_idx].copy()
|
|
1661
|
+
|
|
1662
|
+
# 使用最佳超参数初始化模型(不做搜索,只做一次拟合)
|
|
1663
|
+
# 训练折模型时沿用本 model 的核心预算
|
|
1664
|
+
model_fold = init_model(
|
|
1665
|
+
model_type,
|
|
1666
|
+
task_type,
|
|
1667
|
+
random_state=random_state,
|
|
1668
|
+
cpu_cores=cpu_cores,
|
|
1669
|
+
)
|
|
1670
|
+
try:
|
|
1671
|
+
model_fold.set_params(**best_params)
|
|
1672
|
+
except ValueError as e:
|
|
1673
|
+
# 如果存在不兼容的参数(极少数情况),记录警告并继续使用默认参数
|
|
1674
|
+
logger.warning(f"[Fold {fold_idx}] Failed to apply best_params to model: {e}")
|
|
1675
|
+
|
|
1676
|
+
y_pred_fold = None
|
|
1677
|
+
y_prob_fold = None
|
|
1678
|
+
try:
|
|
1679
|
+
model_fold.fit(X_train_fold, y_train_fold)
|
|
1680
|
+
y_pred_fold = model_fold.predict(X_val_fold)
|
|
1681
|
+
if task_type == "classification":
|
|
1682
|
+
try:
|
|
1683
|
+
if hasattr(model_fold, "predict_proba"):
|
|
1684
|
+
y_prob_fold = model_fold.predict_proba(X_val_fold)
|
|
1685
|
+
elif hasattr(model_fold, "decision_function"):
|
|
1686
|
+
decision_scores = model_fold.decision_function(X_val_fold)
|
|
1687
|
+
from sklearn.utils.extmath import softmax
|
|
1688
|
+
if len(decision_scores.shape) == 1:
|
|
1689
|
+
prob_neg = 1 / (1 + np.exp(decision_scores))
|
|
1690
|
+
prob_pos = 1 - prob_neg
|
|
1691
|
+
y_prob_fold = np.column_stack([prob_neg, prob_pos])
|
|
1692
|
+
else:
|
|
1693
|
+
y_prob_fold = softmax(decision_scores)
|
|
1694
|
+
except Exception as e:
|
|
1695
|
+
logger.warning(f"[Fold {fold_idx}] Failed to obtain prediction probabilities: {str(e)}")
|
|
1696
|
+
except Exception as e:
|
|
1697
|
+
logger.error(f"[Fold {fold_idx}] Error during training/evaluation: {str(e)}", exc_info=True)
|
|
1698
|
+
cv_fold_metrics.append({})
|
|
1699
|
+
continue
|
|
1700
|
+
|
|
1701
|
+
fold_metrics = evaluate_model(y_val_fold, y_pred_fold, y_prob_fold, task_type)
|
|
1702
|
+
|
|
1703
|
+
# 日志输出每折关键指标
|
|
1704
|
+
if task_type == "regression":
|
|
1705
|
+
corr = fold_metrics.get("pearson_correlation", "N/A")
|
|
1706
|
+
logger.info(f"[{fold_idx}/{n_folds}] Pearson correlation: {corr}")
|
|
1707
|
+
else:
|
|
1708
|
+
acc = fold_metrics.get("accuracy", "N/A")
|
|
1709
|
+
auc = fold_metrics.get("auc", "N/A")
|
|
1710
|
+
logger.info(f"[{fold_idx}/{n_folds}] Accuracy: {acc}, AUC: {auc}")
|
|
1711
|
+
|
|
1712
|
+
cv_fold_metrics.append(fold_metrics)
|
|
1713
|
+
all_y_test.append(y_val_fold)
|
|
1714
|
+
all_y_pred.append(y_pred_fold)
|
|
1715
|
+
if y_prob_fold is not None:
|
|
1716
|
+
all_y_prob.append(y_prob_fold)
|
|
1717
|
+
|
|
1718
|
+
# 计算平均指标
|
|
1719
|
+
logger.info(f"{n_folds}-fold cross-validation average results:")
|
|
1720
|
+
|
|
1721
|
+
avg_metrics = {}
|
|
1722
|
+
if task_type == "regression":
|
|
1723
|
+
pearson_corrs = [m.get('pearson_correlation', 0) for m in cv_fold_metrics
|
|
1724
|
+
if isinstance(m.get('pearson_correlation'), (int, float))]
|
|
1725
|
+
pearson_pvalues = [m.get('pearson_pvalue', 1) for m in cv_fold_metrics
|
|
1726
|
+
if isinstance(m.get('pearson_pvalue'), (int, float))]
|
|
1727
|
+
if pearson_corrs:
|
|
1728
|
+
avg_metrics['pearson_correlation'] = float(round(np.mean(pearson_corrs), 4))
|
|
1729
|
+
avg_metrics['pearson_correlation_std'] = float(round(np.std(pearson_corrs), 4))
|
|
1730
|
+
if pearson_pvalues:
|
|
1731
|
+
mean_p = float(np.mean(pearson_pvalues))
|
|
1732
|
+
std_p = float(np.std(pearson_pvalues))
|
|
1733
|
+
# 对极小的平均p值和标准差使用科学计数法表示
|
|
1734
|
+
if mean_p < 1e-6:
|
|
1735
|
+
avg_metrics['pearson_pvalue'] = float(f"{mean_p:.2e}")
|
|
1736
|
+
else:
|
|
1737
|
+
avg_metrics['pearson_pvalue'] = float(round(mean_p, 6))
|
|
1738
|
+
if std_p < 1e-6:
|
|
1739
|
+
avg_metrics['pearson_pvalue_std'] = float(f"{std_p:.2e}")
|
|
1740
|
+
else:
|
|
1741
|
+
avg_metrics['pearson_pvalue_std'] = float(round(std_p, 6))
|
|
1742
|
+
else:
|
|
1743
|
+
accuracies = [m.get('accuracy', 0) for m in cv_fold_metrics
|
|
1744
|
+
if isinstance(m.get('accuracy'), (int, float))]
|
|
1745
|
+
recalls = [m.get('recall', 0) for m in cv_fold_metrics
|
|
1746
|
+
if isinstance(m.get('recall'), (int, float))]
|
|
1747
|
+
f1_scores = [m.get('f1', 0) for m in cv_fold_metrics
|
|
1748
|
+
if isinstance(m.get('f1'), (int, float))]
|
|
1749
|
+
aucs = [m.get('auc', 0) for m in cv_fold_metrics
|
|
1750
|
+
if isinstance(m.get('auc'), (int, float)) and m.get('auc') != 'N/A']
|
|
1751
|
+
|
|
1752
|
+
if accuracies:
|
|
1753
|
+
avg_metrics['accuracy'] = float(round(np.mean(accuracies), 4))
|
|
1754
|
+
avg_metrics['accuracy_std'] = float(round(np.std(accuracies), 4))
|
|
1755
|
+
if recalls:
|
|
1756
|
+
avg_metrics['recall'] = float(round(np.mean(recalls), 4))
|
|
1757
|
+
avg_metrics['recall_std'] = float(round(np.std(recalls), 4))
|
|
1758
|
+
if f1_scores:
|
|
1759
|
+
avg_metrics['f1'] = float(round(np.mean(f1_scores), 4))
|
|
1760
|
+
avg_metrics['f1_std'] = float(round(np.std(f1_scores), 4))
|
|
1761
|
+
if aucs:
|
|
1762
|
+
avg_metrics['auc'] = float(round(np.mean(aucs), 4))
|
|
1763
|
+
avg_metrics['auc_std'] = float(round(np.std(aucs), 4))
|
|
1764
|
+
|
|
1765
|
+
logger.info(f" {avg_metrics}")
|
|
1766
|
+
|
|
1767
|
+
# Step 9: 使用全部数据训练最终模型(用于特征重要性)
|
|
1768
|
+
param_grid = get_param_grid(model_type, task_type)
|
|
1769
|
+
final_model = perform_grid_search(
|
|
1770
|
+
model_type=model_type,
|
|
1771
|
+
task_type=task_type,
|
|
1772
|
+
X_train=X_filtered,
|
|
1773
|
+
y_train=y,
|
|
1774
|
+
param_grid=param_grid,
|
|
1775
|
+
n_iter=30, # 最终模型使用更多迭代次数
|
|
1776
|
+
cv=5, # 使用5折交叉验证
|
|
1777
|
+
random_state=random_state,
|
|
1778
|
+
cpu_cores=cpu_cores,
|
|
1779
|
+
)
|
|
1780
|
+
logger.info("Final model training completed")
|
|
1781
|
+
|
|
1782
|
+
# 合并所有折的预测结果
|
|
1783
|
+
# 修复:确保Series合并时索引唯一,避免重复索引导致的错误
|
|
1784
|
+
# 在交叉验证中,每个fold的验证集互不重叠,但为了安全起见,使用ignore_index=False保留原始索引
|
|
1785
|
+
# 如果确实有重复索引,使用keys参数区分不同fold
|
|
1786
|
+
if all_y_test:
|
|
1787
|
+
try:
|
|
1788
|
+
y_test_combined = pd.concat(all_y_test, axis=0, ignore_index=False)
|
|
1789
|
+
# 检查是否有重复索引(理论上不应该有,因为交叉验证的fold互不重叠)
|
|
1790
|
+
if y_test_combined.index.duplicated().any():
|
|
1791
|
+
logger.warning(" Detected duplicate indices in y_test, using keys to distinguish folds")
|
|
1792
|
+
y_test_combined = pd.concat(all_y_test, axis=0, keys=range(len(all_y_test)), names=['fold', None])
|
|
1793
|
+
y_test_combined = y_test_combined.droplevel(0) # 移除fold级别,保留原始索引
|
|
1794
|
+
except Exception as e:
|
|
1795
|
+
logger.warning(f" Failed to concatenate y_test with original indices: {e}, using ignore_index=True")
|
|
1796
|
+
y_test_combined = pd.concat(all_y_test, axis=0, ignore_index=True)
|
|
1797
|
+
else:
|
|
1798
|
+
y_test_combined = None
|
|
1799
|
+
|
|
1800
|
+
# 修复:确保numpy数组正确连接
|
|
1801
|
+
y_pred_combined = np.concatenate(all_y_pred) if all_y_pred else None
|
|
1802
|
+
|
|
1803
|
+
# 修复:确保概率矩阵正确堆叠,处理可能的维度不一致问题
|
|
1804
|
+
if all_y_prob and len(all_y_prob) > 0:
|
|
1805
|
+
try:
|
|
1806
|
+
# 检查所有概率数组的形状是否一致
|
|
1807
|
+
shapes = [prob.shape for prob in all_y_prob]
|
|
1808
|
+
if len(set(shapes)) > 1:
|
|
1809
|
+
logger.warning(f" Inconsistent probability shapes: {shapes}, attempting to align")
|
|
1810
|
+
# 如果形状不一致,尝试找到最小维度并截断
|
|
1811
|
+
min_shape = min(shapes, key=lambda x: x[0])
|
|
1812
|
+
all_y_prob = [prob[:min_shape[0]] if prob.shape[0] > min_shape[0] else prob for prob in all_y_prob]
|
|
1813
|
+
y_prob_combined = np.vstack(all_y_prob)
|
|
1814
|
+
except Exception as e:
|
|
1815
|
+
logger.warning(f" Failed to stack probability arrays: {e}")
|
|
1816
|
+
y_prob_combined = None
|
|
1817
|
+
else:
|
|
1818
|
+
y_prob_combined = None
|
|
1819
|
+
|
|
1820
|
+
metrics = avg_metrics # 使用平均指标作为最终指标
|
|
1821
|
+
|
|
1822
|
+
# Step 10: 计算特征重要性(可选,使用最终模型)
|
|
1823
|
+
feature_importance_df = None
|
|
1824
|
+
if calculate_feature_importance:
|
|
1825
|
+
feature_cols = filter_phenotype_columns(X_filtered.columns.tolist())
|
|
1826
|
+
|
|
1827
|
+
feature_importance_df = _calculate_feature_importance_func(
|
|
1828
|
+
model=final_model,
|
|
1829
|
+
model_type=model_type,
|
|
1830
|
+
feature_names=feature_cols,
|
|
1831
|
+
X_train=X_filtered[feature_cols] if feature_cols else X_filtered,
|
|
1832
|
+
y_train=y,
|
|
1833
|
+
task_type=task_type
|
|
1834
|
+
)
|
|
1835
|
+
logger.info("Feature importance calculation completed")
|
|
1836
|
+
|
|
1837
|
+
# Step 10.5: 计算SHAP值(默认计算,使用最终模型)
|
|
1838
|
+
# SVM 使用 KernelExplainer(分类任务使用 predict_proba,回归任务使用 predict)
|
|
1839
|
+
feature_cols = filter_phenotype_columns(X_filtered.columns.tolist())
|
|
1840
|
+
|
|
1841
|
+
shap_df = calculate_shap_values(
|
|
1842
|
+
model=final_model,
|
|
1843
|
+
model_type=model_type,
|
|
1844
|
+
feature_names=feature_cols,
|
|
1845
|
+
X_train=X_filtered[feature_cols] if feature_cols else X_filtered,
|
|
1846
|
+
y_train=y,
|
|
1847
|
+
task_type=task_type
|
|
1848
|
+
)
|
|
1849
|
+
if not shap_df.empty:
|
|
1850
|
+
logger.info("SHAP values calculation completed")
|
|
1851
|
+
else:
|
|
1852
|
+
# 兜底:空结果可能来自真正失败(如shap未安装或计算错误)
|
|
1853
|
+
logger.warning(" SHAP values calculation failed or returned empty results")
|
|
1854
|
+
# 如果SHAP值计算失败,使用feature importance作为替代
|
|
1855
|
+
if feature_importance_df is not None and not feature_importance_df.empty:
|
|
1856
|
+
logger.info(" Using feature importance as alternative to SHAP values")
|
|
1857
|
+
# 将feature importance转换为SHAP格式
|
|
1858
|
+
shap_df = feature_importance_df.copy()
|
|
1859
|
+
shap_df = shap_df.rename(columns={'importance_abs': 'shap_abs'})
|
|
1860
|
+
# 确保列顺序正确:feature, shap_abs, effect
|
|
1861
|
+
shap_df = shap_df[['feature', 'shap_abs', 'effect']]
|
|
1862
|
+
logger.info(" Feature importance converted to SHAP format as fallback")
|
|
1863
|
+
else:
|
|
1864
|
+
# 如果feature importance也没有计算,尝试计算它
|
|
1865
|
+
logger.info(" Attempting to calculate feature importance as fallback for SHAP values")
|
|
1866
|
+
feature_importance_fallback = _calculate_feature_importance_func(
|
|
1867
|
+
model=final_model,
|
|
1868
|
+
model_type=model_type,
|
|
1869
|
+
feature_names=feature_cols,
|
|
1870
|
+
X_train=X_filtered[feature_cols] if feature_cols else X_filtered,
|
|
1871
|
+
y_train=y,
|
|
1872
|
+
task_type=task_type
|
|
1873
|
+
)
|
|
1874
|
+
if not feature_importance_fallback.empty:
|
|
1875
|
+
logger.info(" Feature importance calculated successfully, using as SHAP alternative")
|
|
1876
|
+
# 将feature importance转换为SHAP格式
|
|
1877
|
+
shap_df = feature_importance_fallback.copy()
|
|
1878
|
+
shap_df = shap_df.rename(columns={'importance_abs': 'shap_abs'})
|
|
1879
|
+
# 确保列顺序正确:feature, shap_abs, effect
|
|
1880
|
+
shap_df = shap_df[['feature', 'shap_abs', 'effect']]
|
|
1881
|
+
# 同时更新feature_importance_df,以便后续保存
|
|
1882
|
+
if feature_importance_df is None:
|
|
1883
|
+
feature_importance_df = feature_importance_fallback
|
|
1884
|
+
else:
|
|
1885
|
+
logger.warning(" Both SHAP values and feature importance calculation failed")
|
|
1886
|
+
|
|
1887
|
+
# Step 11: 保存结果
|
|
1888
|
+
model_dir = Path(output_dir) / model_type
|
|
1889
|
+
model_dir.mkdir(parents=True, exist_ok=True)
|
|
1890
|
+
|
|
1891
|
+
# 保存交叉验证结果
|
|
1892
|
+
cv_results = {
|
|
1893
|
+
'n_folds': n_folds,
|
|
1894
|
+
'fold_metrics': cv_fold_metrics,
|
|
1895
|
+
'average_metrics': avg_metrics
|
|
1896
|
+
}
|
|
1897
|
+
cv_results_file = model_dir / f"{model_type}_cv_results.json"
|
|
1898
|
+
with open(cv_results_file, 'w') as f:
|
|
1899
|
+
json.dump(cv_results, f, indent=2, default=str)
|
|
1900
|
+
|
|
1901
|
+
# 训练阶段不再做GWAS/LD特征筛选,这里将全部特征名(去掉phenotype列)作为selected_snps
|
|
1902
|
+
selected_snps = filter_phenotype_columns(X_filtered.columns.tolist())
|
|
1903
|
+
|
|
1904
|
+
save_training_results(
|
|
1905
|
+
model=final_model,
|
|
1906
|
+
metrics=metrics,
|
|
1907
|
+
selected_snps=selected_snps,
|
|
1908
|
+
output_dir=output_dir,
|
|
1909
|
+
model_type=model_type,
|
|
1910
|
+
task_type=task_type,
|
|
1911
|
+
feature_importance_df=feature_importance_df,
|
|
1912
|
+
shap_df=shap_df,
|
|
1913
|
+
y_test=y_test_combined,
|
|
1914
|
+
y_pred=y_pred_combined,
|
|
1915
|
+
y_prob=y_prob_combined,
|
|
1916
|
+
cv_results=cv_results,
|
|
1917
|
+
)
|
|
1918
|
+
|
|
1919
|
+
# 最终日志:输出当前模型的训练用时
|
|
1920
|
+
total_time = round(time.time() - start_time, 2)
|
|
1921
|
+
logger.info(f"Training completed for {model_type} (elapsed time: {total_time} seconds)")
|
|
1922
|
+
# 根据调试开关决定是否清理临时目录/文件
|
|
1923
|
+
if CLEANUP_TEMP_FILES:
|
|
1924
|
+
# 正常结束:仅清理本次运行的临时目录,不清理preprocess的临时目录(preprocess模块不删除tmp目录)
|
|
1925
|
+
try:
|
|
1926
|
+
# 使用全局管理器清理临时文件/目录(不包括preprocess临时目录)
|
|
1927
|
+
_temp_file_manager.cleanup_on_exit(cleanup_preprocess=False)
|
|
1928
|
+
# 若 tmp_root 为空,则一并删除 tmp_root(model training 模块自身的 temp_m 根目录)
|
|
1929
|
+
# 但需要确保不会删除preprocess的tmp目录
|
|
1930
|
+
try:
|
|
1931
|
+
if tmp_root.exists() and not any(tmp_root.iterdir()):
|
|
1932
|
+
# 由于tmp_root是temp_m,preprocess_tmp_dir是tmp_p,名称不同,不会冲突
|
|
1933
|
+
# 但保留检查逻辑作为额外安全措施
|
|
1934
|
+
should_delete = True
|
|
1935
|
+
if preprocess_tmp_dir:
|
|
1936
|
+
preprocess_tmp_dir_abs = Path(preprocess_tmp_dir).absolute()
|
|
1937
|
+
tmp_root_abs = tmp_root.absolute()
|
|
1938
|
+
# 如果tmp_root是preprocess_tmp_dir或其父目录,则不删除(理论上不会发生,因为名称不同)
|
|
1939
|
+
try:
|
|
1940
|
+
# Python 3.9+ 使用 is_relative_to
|
|
1941
|
+
if tmp_root_abs == preprocess_tmp_dir_abs or preprocess_tmp_dir_abs.is_relative_to(tmp_root_abs):
|
|
1942
|
+
should_delete = False
|
|
1943
|
+
except AttributeError:
|
|
1944
|
+
# Python < 3.9 使用其他方法检查
|
|
1945
|
+
try:
|
|
1946
|
+
preprocess_tmp_dir_abs.relative_to(tmp_root_abs)
|
|
1947
|
+
should_delete = False
|
|
1948
|
+
except ValueError:
|
|
1949
|
+
pass # 不是相对路径,可以删除
|
|
1950
|
+
if should_delete:
|
|
1951
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
1952
|
+
except Exception:
|
|
1953
|
+
pass
|
|
1954
|
+
|
|
1955
|
+
# 正常运行完成后,删除preprocess模块产生的tmp_p目录
|
|
1956
|
+
if preprocess_tmp_dir:
|
|
1957
|
+
try:
|
|
1958
|
+
preprocess_tmp_dir_path = Path(preprocess_tmp_dir)
|
|
1959
|
+
if preprocess_tmp_dir_path.exists():
|
|
1960
|
+
shutil.rmtree(preprocess_tmp_dir_path, ignore_errors=True)
|
|
1961
|
+
logger.info(f"Deleted preprocess temporary directory: {preprocess_tmp_dir_path}")
|
|
1962
|
+
except Exception as e:
|
|
1963
|
+
logger.warning(f"Failed to delete preprocess temporary directory (ignored): {e}")
|
|
1964
|
+
|
|
1965
|
+
# 正常运行完成后,删除GWAS运行产生的output目录
|
|
1966
|
+
gwas_output_dir = output_dir_path / "output"
|
|
1967
|
+
if gwas_output_dir.exists():
|
|
1968
|
+
try:
|
|
1969
|
+
shutil.rmtree(gwas_output_dir, ignore_errors=True)
|
|
1970
|
+
except Exception as e:
|
|
1971
|
+
logger.warning(f"Failed to delete GWAS output directory (ignored): {e}")
|
|
1972
|
+
except Exception as cleanup_err:
|
|
1973
|
+
pass
|
|
1974
|
+
else:
|
|
1975
|
+
pass
|
|
1976
|
+
|
|
1977
|
+
# ======================== 阶段结束时清理临时目录(standardized) ========================
|
|
1978
|
+
# 单模型训练只清理“本进程自己的”临时目录。
|
|
1979
|
+
# 注意:train-all 会在父进程统一清理 base_output_dir/tmp,单模型子进程不得删除整个 tmp 根目录。
|
|
1980
|
+
if CLEANUP_TEMP_FILES:
|
|
1981
|
+
try:
|
|
1982
|
+
import shutil
|
|
1983
|
+
if run_tmp_dir.exists():
|
|
1984
|
+
shutil.rmtree(run_tmp_dir, ignore_errors=True)
|
|
1985
|
+
|
|
1986
|
+
# 只在“单模型”模式下做最终 tmp 清理:不管目录是否为空,直接删除。
|
|
1987
|
+
# train-all 的子进程共享同一个 tmp 根目录,会并行写入;最终清理应由父进程统一完成。
|
|
1988
|
+
if preloaded_data is None:
|
|
1989
|
+
try:
|
|
1990
|
+
tmp_train_dir = tmp_root.parent # base_output_dir/tmp/train
|
|
1991
|
+
if tmp_train_dir.exists():
|
|
1992
|
+
shutil.rmtree(tmp_train_dir, ignore_errors=True)
|
|
1993
|
+
except Exception:
|
|
1994
|
+
pass
|
|
1995
|
+
try:
|
|
1996
|
+
tmp_root_dir = tmp_root.parent.parent # base_output_dir/tmp
|
|
1997
|
+
if tmp_root_dir.exists():
|
|
1998
|
+
shutil.rmtree(tmp_root_dir, ignore_errors=True)
|
|
1999
|
+
except Exception:
|
|
2000
|
+
pass
|
|
2001
|
+
except Exception as e:
|
|
2002
|
+
pass
|
|
2003
|
+
|
|
2004
|
+
return 0
|
|
2005
|
+
|
|
2006
|
+
except Exception as e:
|
|
2007
|
+
logger.error(f"Single model training failed: {str(e)}", exc_info=True)
|
|
2008
|
+
# 根据调试开关决定异常时是否清理临时目录/文件
|
|
2009
|
+
if CLEANUP_TEMP_FILES:
|
|
2010
|
+
# 异常:只清理本进程自己的临时目录(避免并行冲突)
|
|
2011
|
+
try:
|
|
2012
|
+
_temp_file_manager.cleanup_on_exit(cleanup_preprocess=False)
|
|
2013
|
+
import shutil
|
|
2014
|
+
if run_tmp_dir.exists():
|
|
2015
|
+
shutil.rmtree(run_tmp_dir, ignore_errors=True)
|
|
2016
|
+
|
|
2017
|
+
# 只在“单模型”模式下做最终 tmp 清理:不管目录是否为空,直接删除。
|
|
2018
|
+
if preloaded_data is None:
|
|
2019
|
+
try:
|
|
2020
|
+
tmp_train_dir = tmp_root.parent # base_output_dir/tmp/train
|
|
2021
|
+
if tmp_train_dir.exists():
|
|
2022
|
+
shutil.rmtree(tmp_train_dir, ignore_errors=True)
|
|
2023
|
+
except Exception:
|
|
2024
|
+
pass
|
|
2025
|
+
try:
|
|
2026
|
+
tmp_root_dir = tmp_root.parent.parent # base_output_dir/tmp
|
|
2027
|
+
if tmp_root_dir.exists():
|
|
2028
|
+
shutil.rmtree(tmp_root_dir, ignore_errors=True)
|
|
2029
|
+
except Exception:
|
|
2030
|
+
pass
|
|
2031
|
+
except Exception as cleanup_err:
|
|
2032
|
+
pass
|
|
2033
|
+
else:
|
|
2034
|
+
pass
|
|
2035
|
+
return 1
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
def _run_single_model_silent(*args, **kwargs) -> int:
|
|
2039
|
+
"""
|
|
2040
|
+
子进程包装器:抑制子进程中的冗长日志输出(standardized)
|
|
2041
|
+
- 父进程负责输出"正在训练哪个模型 / 耗时"等进度信息
|
|
2042
|
+
- 子进程仅保留 WARNING 及以上,避免刷屏
|
|
2043
|
+
|
|
2044
|
+
注意:此函数必须在模块级别定义,以便可以被 multiprocessing 的 pickle 序列化
|
|
2045
|
+
"""
|
|
2046
|
+
import logging as _logging
|
|
2047
|
+
root = _logging.getLogger()
|
|
2048
|
+
old_level = root.level
|
|
2049
|
+
try:
|
|
2050
|
+
root.setLevel(_logging.WARNING)
|
|
2051
|
+
return run_single_model(*args, **kwargs)
|
|
2052
|
+
finally:
|
|
2053
|
+
root.setLevel(old_level)
|
|
2054
|
+
|
|
2055
|
+
|
|
2056
|
+
def run_all_models(
|
|
2057
|
+
input_path: str,
|
|
2058
|
+
output_dir: str,
|
|
2059
|
+
task_type: Optional[str] = None,
|
|
2060
|
+
n_folds: int = 5,
|
|
2061
|
+
random_state: int = 42,
|
|
2062
|
+
# 特征重要性计算参数(可选)
|
|
2063
|
+
calculate_feature_importance: bool = False,
|
|
2064
|
+
) -> int:
|
|
2065
|
+
"""训练所有支持的模型,并生成对比报告"""
|
|
2066
|
+
# 强制要求使用 preprocess 生成的 metadata.json 作为训练入口
|
|
2067
|
+
if not str(input_path).endswith("_metadata.json"):
|
|
2068
|
+
raise ValueError(
|
|
2069
|
+
"Training input must be the preprocess-generated metadata file (*_metadata.json)."
|
|
2070
|
+
f"Current input: {input_path}"
|
|
2071
|
+
)
|
|
2072
|
+
# 首先解析输入路径,获取task_type默认值,并预先加载训练数据,避免每个模型子进程重复读盘
|
|
2073
|
+
input_info = parse_train_input_path(input_path)
|
|
2074
|
+
if task_type is None:
|
|
2075
|
+
task_type = input_info.get("task_type") or "regression"
|
|
2076
|
+
if not input_info.get("task_type"):
|
|
2077
|
+
logger.warning(
|
|
2078
|
+
" task_type not specified and no task_type information in metadata, using default: regression"
|
|
2079
|
+
)
|
|
2080
|
+
else:
|
|
2081
|
+
pass
|
|
2082
|
+
|
|
2083
|
+
# 预先加载训练数据(在主进程中执行一次)
|
|
2084
|
+
train_file = input_info["train_file"]
|
|
2085
|
+
valid_samples = input_info["metadata"]["valid_samples"] if input_info["metadata"] else None
|
|
2086
|
+
X, y, snp_name_mapping = load_training_data(train_file, valid_samples)
|
|
2087
|
+
|
|
2088
|
+
preloaded_data = {
|
|
2089
|
+
"X": X,
|
|
2090
|
+
"y": y,
|
|
2091
|
+
"train_file": train_file,
|
|
2092
|
+
"valid_samples": valid_samples,
|
|
2093
|
+
"metadata": input_info["metadata"],
|
|
2094
|
+
"preprocess_tmp_dir": input_info.get("preprocess_tmp_dir"),
|
|
2095
|
+
"task_type": task_type,
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
supported_models = ["LightGBM", "RandomForest", "XGBoost", "SVM", "CatBoost", "Logistic"]
|
|
2099
|
+
results = {}
|
|
2100
|
+
model_metrics_summary = {} # 记录每个模型的评估指标(用于后续选择最佳模型) # 新增
|
|
2101
|
+
start_time = time.time()
|
|
2102
|
+
|
|
2103
|
+
output_dir_path = Path(output_dir)
|
|
2104
|
+
output_dir_path.mkdir(parents=True, exist_ok=True)
|
|
2105
|
+
|
|
2106
|
+
# ======================== 临时目录规范化(standardized) ========================
|
|
2107
|
+
# 临时文件放在基础输出目录下的 tmp/train 子目录中(与阶段目录同级)
|
|
2108
|
+
# 每个阶段在 tmp 下创建自己的子目录,避免不同阶段的临时文件互相干扰
|
|
2109
|
+
# 例如:如果 output_dir 是 test/train,则 tmp_dir 是 test/tmp/train
|
|
2110
|
+
base_output_dir = output_dir_path.parent
|
|
2111
|
+
tmp_dir = base_output_dir / "tmp" / "train"
|
|
2112
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
2113
|
+
|
|
2114
|
+
try:
|
|
2115
|
+
# ======================== 关键改动:使用进程池并行训练所有模型 ========================
|
|
2116
|
+
# 说明:
|
|
2117
|
+
# - 每个模型类型在一个独立的进程中调用 run_single_model 进行完整训练(含CV、特征重要性、SHAP等)
|
|
2118
|
+
# - 进程池大小根据模型个数和CPU核数自动设置,避免资源过载
|
|
2119
|
+
# - 父进程输出进度与耗时;子进程通过包装器抑制冗长日志
|
|
2120
|
+
max_workers = len(supported_models)
|
|
2121
|
+
cpu_count = os.cpu_count() or max_workers
|
|
2122
|
+
max_workers = min(max_workers, cpu_count)
|
|
2123
|
+
|
|
2124
|
+
# === CPU 核心分配:将系统可用核心数按“并发窗口”平均分配给各 model ===
|
|
2125
|
+
# 冲突处理:保证并发时各进程分配的线程总数不超过 cpu_count。
|
|
2126
|
+
# 若 model 数 > cpu_count,则最多只有 cpu_count 个 model 会并发执行,其它排队等待。
|
|
2127
|
+
base_cores = max(1, cpu_count // max_workers)
|
|
2128
|
+
remainder = cpu_count % max_workers # 余数将分配给前 remainder 个正在并发执行的 model
|
|
2129
|
+
model_cpu_map: Dict[str, int] = {}
|
|
2130
|
+
for idx, mt in enumerate(supported_models):
|
|
2131
|
+
if idx < max_workers:
|
|
2132
|
+
model_cpu_map[mt] = base_cores + (1 if idx < remainder else 0)
|
|
2133
|
+
else:
|
|
2134
|
+
model_cpu_map[mt] = base_cores
|
|
2135
|
+
|
|
2136
|
+
logger.info(
|
|
2137
|
+
f"[TRAIN_ALL][CPU_ALLOC] system_cores={cpu_count}, max_workers={max_workers}, "
|
|
2138
|
+
f"base_cores={base_cores}, remainder={remainder}, "
|
|
2139
|
+
f"model_cpu_map={model_cpu_map}"
|
|
2140
|
+
)
|
|
2141
|
+
|
|
2142
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
2143
|
+
|
|
2144
|
+
logger.info(
|
|
2145
|
+
f"[TRAIN_ALL] 并行训练开始: models={len(supported_models)}, max_workers={max_workers}, "
|
|
2146
|
+
f"task_type={task_type}, n_folds={n_folds}"
|
|
2147
|
+
)
|
|
2148
|
+
|
|
2149
|
+
with ProcessPoolExecutor(max_workers=max_workers) as executor:
|
|
2150
|
+
futures = {}
|
|
2151
|
+
submit_ts = {}
|
|
2152
|
+
for model_type in supported_models:
|
|
2153
|
+
logger.info(f"[TRAIN_ALL] Model training started: {model_type}")
|
|
2154
|
+
submit_ts[model_type] = time.time()
|
|
2155
|
+
future = executor.submit(
|
|
2156
|
+
_run_single_model_silent,
|
|
2157
|
+
input_path,
|
|
2158
|
+
model_type,
|
|
2159
|
+
output_dir,
|
|
2160
|
+
task_type,
|
|
2161
|
+
n_folds,
|
|
2162
|
+
random_state,
|
|
2163
|
+
calculate_feature_importance,
|
|
2164
|
+
preloaded_data,
|
|
2165
|
+
cpu_cores=model_cpu_map.get(model_type, base_cores),
|
|
2166
|
+
)
|
|
2167
|
+
futures[future] = model_type
|
|
2168
|
+
|
|
2169
|
+
# 收集各模型的退出码(按完成顺序输出)
|
|
2170
|
+
for future in as_completed(futures):
|
|
2171
|
+
model_type = futures[future]
|
|
2172
|
+
elapsed = time.time() - submit_ts.get(model_type, time.time())
|
|
2173
|
+
try:
|
|
2174
|
+
ret_code = future.result()
|
|
2175
|
+
except Exception as e:
|
|
2176
|
+
logger.error(f"[TRAIN_ALL] Model training exception: model={model_type}, err={e}", exc_info=True)
|
|
2177
|
+
ret_code = 1
|
|
2178
|
+
results[model_type] = "success" if ret_code == 0 else "failed"
|
|
2179
|
+
logger.info(
|
|
2180
|
+
f"[TRAIN_ALL] 模型训练结束: model={model_type}, status={results[model_type]}, "
|
|
2181
|
+
f"elapsed={elapsed:.1f}s"
|
|
2182
|
+
)
|
|
2183
|
+
|
|
2184
|
+
logger.info(f"[TRAIN_ALL] Parallel training phase completed, elapsed_total={time.time()-start_time:.1f}s")
|
|
2185
|
+
|
|
2186
|
+
# ======================== 阶段结束时清理临时目录(standardized) ========================
|
|
2187
|
+
# 在阶段正常结束时,删除整个 tmp 目录(包括目录本身及其所有文件和子目录)
|
|
2188
|
+
if CLEANUP_TEMP_FILES:
|
|
2189
|
+
try:
|
|
2190
|
+
import shutil
|
|
2191
|
+
# 删除整个 tmp 目录(output_dir/tmp),而不仅仅是 tmp/train 子目录
|
|
2192
|
+
tmp_root_dir = base_output_dir / "tmp"
|
|
2193
|
+
if tmp_root_dir.exists():
|
|
2194
|
+
shutil.rmtree(tmp_root_dir, ignore_errors=True)
|
|
2195
|
+
except Exception as e:
|
|
2196
|
+
pass
|
|
2197
|
+
|
|
2198
|
+
# ======================== 新增:读取各模型评估指标,打印性能并选择最佳模型 ========================
|
|
2199
|
+
# 说明:
|
|
2200
|
+
# - 每个模型的 metrics.json 由 run_single_model/save_training_results 写入
|
|
2201
|
+
# - 分类任务优先使用 AUC 作为选择标准,其次 Accuracy
|
|
2202
|
+
# - 回归任务使用 Pearson correlation 作为选择标准
|
|
2203
|
+
best_model_type = None
|
|
2204
|
+
best_score = None
|
|
2205
|
+
best_metric_name = None
|
|
2206
|
+
|
|
2207
|
+
logger.info("Model performance summary (based on *_metrics.json):")
|
|
2208
|
+
for model_type in supported_models:
|
|
2209
|
+
model_dir = output_dir_path / model_type
|
|
2210
|
+
metrics_file = model_dir / f"{model_type}_metrics.json"
|
|
2211
|
+
if not metrics_file.exists():
|
|
2212
|
+
logger.warning(f" Metrics file not found for model {model_type}: {metrics_file}")
|
|
2213
|
+
continue
|
|
2214
|
+
|
|
2215
|
+
try:
|
|
2216
|
+
with open(metrics_file, "r") as f:
|
|
2217
|
+
metrics_data = json.load(f)
|
|
2218
|
+
except Exception as e:
|
|
2219
|
+
logger.warning(f" Failed to load metrics for model {model_type}: {e}")
|
|
2220
|
+
continue
|
|
2221
|
+
|
|
2222
|
+
metrics = metrics_data.get("metrics", {}) or {}
|
|
2223
|
+
model_metrics_summary[model_type] = metrics
|
|
2224
|
+
|
|
2225
|
+
if task_type == "classification":
|
|
2226
|
+
# 分类:优先按 AUC 选最佳,其次 Accuracy
|
|
2227
|
+
raw_auc = metrics.get("auc")
|
|
2228
|
+
auc = raw_auc if isinstance(raw_auc, (int, float)) else None
|
|
2229
|
+
raw_acc = metrics.get("accuracy")
|
|
2230
|
+
acc = raw_acc if isinstance(raw_acc, (int, float)) else None
|
|
2231
|
+
|
|
2232
|
+
logger.info(
|
|
2233
|
+
f" {model_type}: accuracy={metrics.get('accuracy')}, "
|
|
2234
|
+
f"recall={metrics.get('recall')}, f1={metrics.get('f1')}, auc={metrics.get('auc')}"
|
|
2235
|
+
)
|
|
2236
|
+
|
|
2237
|
+
score = None
|
|
2238
|
+
metric_name = None
|
|
2239
|
+
if auc is not None:
|
|
2240
|
+
score = auc
|
|
2241
|
+
metric_name = "auc"
|
|
2242
|
+
elif acc is not None:
|
|
2243
|
+
score = acc
|
|
2244
|
+
metric_name = "accuracy"
|
|
2245
|
+
else:
|
|
2246
|
+
# 回归:使用 Pearson correlation 选最佳
|
|
2247
|
+
raw_corr = metrics.get("pearson_correlation")
|
|
2248
|
+
corr = raw_corr if isinstance(raw_corr, (int, float)) else None
|
|
2249
|
+
logger.info(
|
|
2250
|
+
f" {model_type}: pearson_correlation={metrics.get('pearson_correlation')}, "
|
|
2251
|
+
f"pearson_pvalue={metrics.get('pearson_pvalue')}"
|
|
2252
|
+
)
|
|
2253
|
+
score = corr
|
|
2254
|
+
metric_name = "pearson_correlation"
|
|
2255
|
+
|
|
2256
|
+
if score is not None:
|
|
2257
|
+
if best_score is None or score > best_score:
|
|
2258
|
+
best_score = score
|
|
2259
|
+
best_model_type = model_type
|
|
2260
|
+
best_metric_name = metric_name
|
|
2261
|
+
|
|
2262
|
+
# 已按需求:仅保留性能最优模型目录(其内已包含 *_model.pkl 等文件)
|
|
2263
|
+
# 因此不再额外复制 best_model.pkl,避免重复保存
|
|
2264
|
+
if best_model_type is not None:
|
|
2265
|
+
best_model_dir = output_dir_path / best_model_type
|
|
2266
|
+
best_model_src = best_model_dir / f"{best_model_type}_model.pkl"
|
|
2267
|
+
if best_model_src.exists():
|
|
2268
|
+
# 额外保存一个 best_model_info.json,记录最佳模型及所有模型性能
|
|
2269
|
+
best_info_file = output_dir_path / "best_model_info.json"
|
|
2270
|
+
try:
|
|
2271
|
+
with open(best_info_file, "w", encoding="utf-8") as f:
|
|
2272
|
+
json.dump(
|
|
2273
|
+
{
|
|
2274
|
+
"task_type": task_type,
|
|
2275
|
+
"best_model_type": best_model_type,
|
|
2276
|
+
"best_metric_name": best_metric_name,
|
|
2277
|
+
"best_metric_value": best_score,
|
|
2278
|
+
"all_model_metrics": model_metrics_summary,
|
|
2279
|
+
"generated_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
2280
|
+
},
|
|
2281
|
+
f,
|
|
2282
|
+
indent=2,
|
|
2283
|
+
ensure_ascii=False,
|
|
2284
|
+
default=str,
|
|
2285
|
+
)
|
|
2286
|
+
logger.info(
|
|
2287
|
+
f"Best model: {best_model_type} ("
|
|
2288
|
+
f"{best_metric_name}={best_score})"
|
|
2289
|
+
)
|
|
2290
|
+
except Exception as e:
|
|
2291
|
+
logger.warning(f" Failed to save best_model_info.json: {e}")
|
|
2292
|
+
else:
|
|
2293
|
+
logger.warning(
|
|
2294
|
+
f" Best model file not found for model {best_model_type}: {best_model_src}"
|
|
2295
|
+
)
|
|
2296
|
+
|
|
2297
|
+
# 根据用户需求:仅保留性能最优模型,删除其它模型目录
|
|
2298
|
+
for model_type in supported_models:
|
|
2299
|
+
if model_type == best_model_type:
|
|
2300
|
+
continue
|
|
2301
|
+
model_dir = output_dir_path / model_type
|
|
2302
|
+
if model_dir.exists():
|
|
2303
|
+
try:
|
|
2304
|
+
import shutil
|
|
2305
|
+
shutil.rmtree(model_dir, ignore_errors=True)
|
|
2306
|
+
logger.info(f" Removed model directory for non-best model: {model_type}")
|
|
2307
|
+
except Exception as e:
|
|
2308
|
+
logger.warning(f" Failed to remove model directory {model_dir}: {e}")
|
|
2309
|
+
else:
|
|
2310
|
+
logger.warning(" No valid metrics found to select the best model.")
|
|
2311
|
+
|
|
2312
|
+
# 生成模型对比报告(保留原有结构,增加整体训练时间信息)
|
|
2313
|
+
output_dir_path = Path(output_dir)
|
|
2314
|
+
output_dir_path.mkdir(parents=True, exist_ok=True)
|
|
2315
|
+
report_file = output_dir_path / "model_comparison_report.json"
|
|
2316
|
+
with open(report_file, 'w', encoding="utf-8") as f:
|
|
2317
|
+
json.dump({
|
|
2318
|
+
"task_type": task_type,
|
|
2319
|
+
"n_folds": n_folds,
|
|
2320
|
+
"random_state": random_state,
|
|
2321
|
+
"note": "GWAS/LD feature selection is handled in the preprocess module",
|
|
2322
|
+
"training_results": results,
|
|
2323
|
+
"total_training_time": round(time.time() - start_time, 2),
|
|
2324
|
+
"generated_time": time.strftime("%Y-%m-%d %H:%M:%S")
|
|
2325
|
+
}, f, indent=2, ensure_ascii=False)
|
|
2326
|
+
|
|
2327
|
+
logger.info(" All models training completed")
|
|
2328
|
+
|
|
2329
|
+
# ======================== 阶段结束时清理临时目录(standardized) ========================
|
|
2330
|
+
# 在阶段正常结束时,删除整个 tmp 目录(包括目录本身及其所有文件和子目录)
|
|
2331
|
+
if CLEANUP_TEMP_FILES:
|
|
2332
|
+
try:
|
|
2333
|
+
import shutil
|
|
2334
|
+
# 删除整个 tmp 目录(output_dir/tmp),而不仅仅是 tmp/train 子目录
|
|
2335
|
+
tmp_root_dir = base_output_dir / "tmp"
|
|
2336
|
+
if tmp_root_dir.exists():
|
|
2337
|
+
shutil.rmtree(tmp_root_dir, ignore_errors=True)
|
|
2338
|
+
except Exception as e:
|
|
2339
|
+
pass
|
|
2340
|
+
|
|
2341
|
+
# 根据调试开关决定是否清理临时目录/文件(保留原有逻辑作为备用)
|
|
2342
|
+
if CLEANUP_TEMP_FILES:
|
|
2343
|
+
# 所有模型训练结束后,尝试删除全模型阶段的 tmp 根目录(若为空)
|
|
2344
|
+
# 由于tmp_dir是temp_m,preprocess_tmp_dir是tmp_p,名称不同,不会冲突
|
|
2345
|
+
# 但保留检查逻辑作为额外安全措施
|
|
2346
|
+
try:
|
|
2347
|
+
if tmp_dir.exists() and not any(tmp_dir.iterdir()):
|
|
2348
|
+
should_delete = True
|
|
2349
|
+
# 尝试从输入路径获取preprocess_tmp_dir(tmp_p目录)
|
|
2350
|
+
preprocess_tmp_dir = None
|
|
2351
|
+
try:
|
|
2352
|
+
input_info = parse_train_input_path(input_path)
|
|
2353
|
+
if input_info.get("preprocess_tmp_dir"):
|
|
2354
|
+
preprocess_tmp_dir_abs = Path(input_info["preprocess_tmp_dir"]).absolute()
|
|
2355
|
+
preprocess_tmp_dir = preprocess_tmp_dir_abs
|
|
2356
|
+
tmp_dir_abs = tmp_dir.absolute()
|
|
2357
|
+
# 如果tmp_dir是preprocess_tmp_dir或其父目录,则不删除(理论上不会发生,因为名称不同)
|
|
2358
|
+
try:
|
|
2359
|
+
# Python 3.9+ 使用 is_relative_to
|
|
2360
|
+
if tmp_dir_abs == preprocess_tmp_dir_abs or preprocess_tmp_dir_abs.is_relative_to(tmp_dir_abs):
|
|
2361
|
+
should_delete = False
|
|
2362
|
+
except AttributeError:
|
|
2363
|
+
# Python < 3.9 使用其他方法检查
|
|
2364
|
+
try:
|
|
2365
|
+
preprocess_tmp_dir_abs.relative_to(tmp_dir_abs)
|
|
2366
|
+
should_delete = False
|
|
2367
|
+
except ValueError:
|
|
2368
|
+
pass # 不是相对路径,可以删除
|
|
2369
|
+
except Exception:
|
|
2370
|
+
pass # 如果无法获取preprocess_tmp_dir,则按原逻辑删除
|
|
2371
|
+
|
|
2372
|
+
if should_delete:
|
|
2373
|
+
import shutil
|
|
2374
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
2375
|
+
except Exception as cleanup_err:
|
|
2376
|
+
logger.warning(f" Failed to delete all-models training temporary directory (ignored): {cleanup_err}")
|
|
2377
|
+
|
|
2378
|
+
# 正常运行完成后,删除preprocess模块产生的tmp_p目录
|
|
2379
|
+
try:
|
|
2380
|
+
input_info = parse_train_input_path(input_path)
|
|
2381
|
+
if input_info.get("preprocess_tmp_dir"):
|
|
2382
|
+
preprocess_tmp_dir_path = Path(input_info["preprocess_tmp_dir"]).absolute()
|
|
2383
|
+
if preprocess_tmp_dir_path.exists():
|
|
2384
|
+
shutil.rmtree(preprocess_tmp_dir_path, ignore_errors=True)
|
|
2385
|
+
logger.info(f"Deleted preprocess temporary directory: {preprocess_tmp_dir_path}")
|
|
2386
|
+
except Exception as e:
|
|
2387
|
+
logger.warning(f"Failed to delete preprocess temporary directory (ignored): {e}")
|
|
2388
|
+
|
|
2389
|
+
# 正常运行完成后,删除GWAS运行产生的output目录
|
|
2390
|
+
gwas_output_dir = output_dir_path / "output"
|
|
2391
|
+
if gwas_output_dir.exists():
|
|
2392
|
+
try:
|
|
2393
|
+
shutil.rmtree(gwas_output_dir, ignore_errors=True)
|
|
2394
|
+
except Exception as e:
|
|
2395
|
+
logger.warning(f"Failed to delete GWAS output directory (ignored): {e}")
|
|
2396
|
+
else:
|
|
2397
|
+
pass
|
|
2398
|
+
|
|
2399
|
+
return 0
|
|
2400
|
+
|
|
2401
|
+
except Exception as e:
|
|
2402
|
+
logger.error(f" All-models training failed: {str(e)}", exc_info=True)
|
|
2403
|
+
# 异常时清理整个 tmp 目录(包括目录本身及其所有文件和子目录)
|
|
2404
|
+
if CLEANUP_TEMP_FILES:
|
|
2405
|
+
try:
|
|
2406
|
+
import shutil
|
|
2407
|
+
# 删除整个 tmp 目录(output_dir/tmp),而不仅仅是 tmp/train 子目录
|
|
2408
|
+
tmp_root_dir = base_output_dir / "tmp"
|
|
2409
|
+
if tmp_root_dir.exists():
|
|
2410
|
+
shutil.rmtree(tmp_root_dir, ignore_errors=True)
|
|
2411
|
+
except Exception:
|
|
2412
|
+
pass
|
|
2413
|
+
return 1
|
|
2414
|
+
|
|
2415
|
+
# ======================== 6. 预测函数 ========================
|
|
2416
|
+
def predict_with_model(
|
|
2417
|
+
input_path: str,
|
|
2418
|
+
model_path: str,
|
|
2419
|
+
output_dir: str,
|
|
2420
|
+
task_type: str
|
|
2421
|
+
) -> int:
|
|
2422
|
+
"""使用训练好的模型进行预测
|
|
2423
|
+
|
|
2424
|
+
支持输入格式:
|
|
2425
|
+
- 训练数据格式(.txt,包含sample列作为索引)
|
|
2426
|
+
- VCF格式(.vcf/.vcf.gz),会自动转换为训练数据格式
|
|
2427
|
+
"""
|
|
2428
|
+
try:
|
|
2429
|
+
# 保存原始输入路径(用于后续清理)
|
|
2430
|
+
original_input_path = input_path
|
|
2431
|
+
input_path_obj = Path(input_path)
|
|
2432
|
+
file_ext = input_path_obj.suffix.lower()
|
|
2433
|
+
is_vcf_input = file_ext in ['.vcf', '.gz'] or input_path.endswith('.vcf.gz')
|
|
2434
|
+
tmp_dir_to_clean = None
|
|
2435
|
+
|
|
2436
|
+
# 如果是VCF文件,先转换为训练数据格式
|
|
2437
|
+
if is_vcf_input:
|
|
2438
|
+
logger.info(f"Detected VCF file: {input_path}")
|
|
2439
|
+
logger.info("Converting VCF to training data format...")
|
|
2440
|
+
|
|
2441
|
+
# 导入preprocess模块的函数
|
|
2442
|
+
from G2PInsight.bin.preprocess import (
|
|
2443
|
+
genotype_to_plink,
|
|
2444
|
+
plink_to_training_data_optimized,
|
|
2445
|
+
auto_detect_chromosomes
|
|
2446
|
+
)
|
|
2447
|
+
import tempfile
|
|
2448
|
+
import shutil
|
|
2449
|
+
|
|
2450
|
+
# 创建临时目录
|
|
2451
|
+
output_dir_path = Path(output_dir)
|
|
2452
|
+
tmp_dir = output_dir_path / "tmp_predict"
|
|
2453
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
2454
|
+
tmp_dir_to_clean = tmp_dir # 保存临时目录路径用于清理
|
|
2455
|
+
|
|
2456
|
+
try:
|
|
2457
|
+
# 步骤1: VCF转PLINK
|
|
2458
|
+
plink_prefix = str(tmp_dir / "predict_plink")
|
|
2459
|
+
logger.info(" Step 1: Converting VCF to PLINK format...")
|
|
2460
|
+
plink_prefix = genotype_to_plink(input_path, plink_prefix)
|
|
2461
|
+
logger.info(f" PLINK conversion completed: {plink_prefix}")
|
|
2462
|
+
|
|
2463
|
+
# 步骤2: PLINK转训练数据格式
|
|
2464
|
+
# 对于预测,我们不需要表型数据,创建一个空的表型DataFrame
|
|
2465
|
+
# 但需要确保样本ID匹配
|
|
2466
|
+
logger.info(" Step 2: Converting PLINK to training data format...")
|
|
2467
|
+
|
|
2468
|
+
# 读取.fam文件获取样本ID
|
|
2469
|
+
fam_file = f"{plink_prefix}.fam"
|
|
2470
|
+
if not Path(fam_file).exists():
|
|
2471
|
+
raise FileNotFoundError(f"PLINK .fam file not found: {fam_file}")
|
|
2472
|
+
|
|
2473
|
+
# 读取样本ID
|
|
2474
|
+
fam_df = pd.read_csv(fam_file, sep=r"\s+", header=None,
|
|
2475
|
+
names=["FID", "IID", "PAT", "MAT", "SEX", "PHENOTYPE"])
|
|
2476
|
+
sample_ids = fam_df["IID"].tolist()
|
|
2477
|
+
|
|
2478
|
+
# 创建空的表型DataFrame(所有值设为0,仅用于占位)
|
|
2479
|
+
pheno_df = pd.DataFrame({
|
|
2480
|
+
"sample": sample_ids,
|
|
2481
|
+
"phenotype": [0] * len(sample_ids)
|
|
2482
|
+
}).set_index("sample")
|
|
2483
|
+
|
|
2484
|
+
# 生成临时训练数据文件
|
|
2485
|
+
temp_train_file = str(tmp_dir / "predict_train_data.txt")
|
|
2486
|
+
|
|
2487
|
+
# 调用plink_to_training_data_optimized
|
|
2488
|
+
train_df, actual_train_file = plink_to_training_data_optimized(
|
|
2489
|
+
plink_prefix=plink_prefix,
|
|
2490
|
+
pheno_df=pheno_df,
|
|
2491
|
+
output_file=temp_train_file,
|
|
2492
|
+
tmp_dir=str(tmp_dir),
|
|
2493
|
+
use_cache=True
|
|
2494
|
+
)
|
|
2495
|
+
|
|
2496
|
+
logger.info(f" Training data conversion completed: {actual_train_file}")
|
|
2497
|
+
|
|
2498
|
+
# 使用转换后的文件作为输入
|
|
2499
|
+
input_path = actual_train_file
|
|
2500
|
+
|
|
2501
|
+
except Exception as e:
|
|
2502
|
+
logger.error(f" Failed to convert VCF to training data format: {str(e)}")
|
|
2503
|
+
raise
|
|
2504
|
+
|
|
2505
|
+
# 0. 解析模型路径:仅支持直接指定训练好的模型文件路径(*.pkl)
|
|
2506
|
+
model_path_obj = Path(model_path).absolute()
|
|
2507
|
+
model_dir: Path
|
|
2508
|
+
model_file: Path
|
|
2509
|
+
model_type: str = ""
|
|
2510
|
+
|
|
2511
|
+
if not model_path_obj.is_file():
|
|
2512
|
+
raise FileNotFoundError(f"Model file does not exist or is not a valid file path: {model_path_obj}")
|
|
2513
|
+
|
|
2514
|
+
model_file = model_path_obj
|
|
2515
|
+
model_dir = model_file.parent
|
|
2516
|
+
name = model_file.stem
|
|
2517
|
+
if name.endswith("_model"):
|
|
2518
|
+
model_type = name[:-6]
|
|
2519
|
+
else:
|
|
2520
|
+
model_type = name
|
|
2521
|
+
|
|
2522
|
+
# 1. 加载预测数据(特征矩阵)
|
|
2523
|
+
# 说明:
|
|
2524
|
+
# - 这里复用 load_training_data 以保证特征清洗规则一致(clean_feature_names 等);
|
|
2525
|
+
# - 对于纯预测数据,如果没有 phenotype 列,则最后一列会被当作 y 丢弃,
|
|
2526
|
+
# 因此更推荐采用与训练相同格式的矩阵(包含 phenotype 列),或在上游统一格式。
|
|
2527
|
+
X, _, _ = load_training_data(input_path)
|
|
2528
|
+
|
|
2529
|
+
# 1.1 加载训练阶段使用的特征列表
|
|
2530
|
+
features_file = model_dir / f"{model_type}_training_features.json"
|
|
2531
|
+
if features_file.exists():
|
|
2532
|
+
try:
|
|
2533
|
+
with open(features_file, "r") as f:
|
|
2534
|
+
feature_info = json.load(f)
|
|
2535
|
+
train_features = feature_info.get("feature_names") or []
|
|
2536
|
+
except Exception as e:
|
|
2537
|
+
logger.warning(f" Failed to load training_features.json, fallback to raw X columns: {e}")
|
|
2538
|
+
train_features = list(X.columns)
|
|
2539
|
+
else:
|
|
2540
|
+
logger.warning(
|
|
2541
|
+
" training_features.json not found, using current X columns as features; "
|
|
2542
|
+
"train/predict feature alignment may be unreliable."
|
|
2543
|
+
)
|
|
2544
|
+
train_features = list(X.columns)
|
|
2545
|
+
|
|
2546
|
+
# 1.2 按训练特征集合对齐预测特征:
|
|
2547
|
+
# - 训练有、预测也有:直接使用预测中的该列;
|
|
2548
|
+
# - 训练有、预测没有:在预测矩阵中补一列,整列填 0;
|
|
2549
|
+
# - 训练没有、预测有:不加入模型输入(即丢弃该列)。
|
|
2550
|
+
|
|
2551
|
+
# 调试信息:检查特征匹配情况
|
|
2552
|
+
|
|
2553
|
+
# 检查特征名称匹配情况
|
|
2554
|
+
matched_features = [col for col in train_features if col in X.columns]
|
|
2555
|
+
missing_in_predict = [col for col in train_features if col not in X.columns]
|
|
2556
|
+
extra_in_predict = [col for col in X.columns if col not in train_features]
|
|
2557
|
+
|
|
2558
|
+
logger.info(f" Feature alignment: {len(matched_features)} matched, {len(missing_in_predict)} missing, {len(extra_in_predict)} extra")
|
|
2559
|
+
|
|
2560
|
+
if len(matched_features) == 0:
|
|
2561
|
+
logger.warning(
|
|
2562
|
+
f" WARNING: No features matched between training and prediction data!\n"
|
|
2563
|
+
f" This will result in all-zero feature matrix and constant predictions.\n"
|
|
2564
|
+
f" Training features (first 20): {train_features[:20]}\n"
|
|
2565
|
+
f" Prediction features (first 20): {list(X.columns[:20])}\n"
|
|
2566
|
+
f" This may be due to feature name cleaning differences. Please check feature names."
|
|
2567
|
+
)
|
|
2568
|
+
|
|
2569
|
+
if len(missing_in_predict) > len(matched_features):
|
|
2570
|
+
logger.warning(
|
|
2571
|
+
f" WARNING: More features missing ({len(missing_in_predict)}) than matched ({len(matched_features)}).\n"
|
|
2572
|
+
f" This may result in poor prediction quality."
|
|
2573
|
+
)
|
|
2574
|
+
|
|
2575
|
+
X_aligned = pd.DataFrame(index=X.index)
|
|
2576
|
+
# 重新计算 missing_in_predict(避免重复)
|
|
2577
|
+
missing_in_predict = [col for col in train_features if col not in X.columns]
|
|
2578
|
+
|
|
2579
|
+
for col in train_features:
|
|
2580
|
+
if col in X.columns:
|
|
2581
|
+
X_aligned[col] = X[col]
|
|
2582
|
+
else:
|
|
2583
|
+
X_aligned[col] = 0
|
|
2584
|
+
|
|
2585
|
+
# 验证对齐后的特征矩阵
|
|
2586
|
+
if X_aligned.empty:
|
|
2587
|
+
raise ValueError("Aligned feature matrix is empty!")
|
|
2588
|
+
|
|
2589
|
+
# 检查是否所有特征值都是0
|
|
2590
|
+
non_zero_counts = (X_aligned != 0).sum(axis=1)
|
|
2591
|
+
if (non_zero_counts == 0).all():
|
|
2592
|
+
logger.error(
|
|
2593
|
+
f" ERROR: All feature values are zero! This will result in constant predictions.\n"
|
|
2594
|
+
f" Matched features: {len(matched_features)}\n"
|
|
2595
|
+
f" Missing features: {len(missing_in_predict)}\n"
|
|
2596
|
+
f" Please check if feature names match between training and prediction data."
|
|
2597
|
+
)
|
|
2598
|
+
else:
|
|
2599
|
+
pass
|
|
2600
|
+
|
|
2601
|
+
if missing_in_predict:
|
|
2602
|
+
logger.info(
|
|
2603
|
+
f" {len(missing_in_predict)} feature(s) present in training but missing in predict; "
|
|
2604
|
+
f"filled with 0. Example: {missing_in_predict[:5]}"
|
|
2605
|
+
)
|
|
2606
|
+
|
|
2607
|
+
# 2. 加载训练好的模型
|
|
2608
|
+
if not model_file.exists():
|
|
2609
|
+
raise FileNotFoundError(f"Model file not found: {model_file}")
|
|
2610
|
+
import joblib
|
|
2611
|
+
model = joblib.load(model_file)
|
|
2612
|
+
logger.info(" Loading pre-trained model")
|
|
2613
|
+
|
|
2614
|
+
# 3. 执行预测(基于对齐后的特征矩阵)
|
|
2615
|
+
# 验证特征矩阵的有效性
|
|
2616
|
+
|
|
2617
|
+
# 检查特征矩阵是否有变化
|
|
2618
|
+
feature_variance = X_aligned.var()
|
|
2619
|
+
zero_variance_features = (feature_variance == 0).sum()
|
|
2620
|
+
if zero_variance_features > 0:
|
|
2621
|
+
logger.warning(f" WARNING: {zero_variance_features} features have zero variance (constant values)")
|
|
2622
|
+
|
|
2623
|
+
y_pred = model.predict(X_aligned)
|
|
2624
|
+
y_prob = model.predict_proba(X_aligned) if task_type == "classification" and hasattr(model, "predict_proba") else None
|
|
2625
|
+
|
|
2626
|
+
# 检查预测结果的多样性
|
|
2627
|
+
unique_predictions = len(np.unique(y_pred))
|
|
2628
|
+
logger.info(f" Prediction statistics: {unique_predictions} unique values out of {len(y_pred)} samples")
|
|
2629
|
+
if unique_predictions == 1:
|
|
2630
|
+
logger.error(
|
|
2631
|
+
f" ERROR: All predictions are the same value: {y_pred[0]}\n"
|
|
2632
|
+
f" This usually indicates one of the following issues:\n"
|
|
2633
|
+
f" 1. Feature names don't match between training and prediction data\n"
|
|
2634
|
+
f" 2. All feature values are zero (check matched_features: {len(matched_features)})\n"
|
|
2635
|
+
f" 3. Model is predicting a constant value for all samples\n"
|
|
2636
|
+
f" Please check the feature alignment logs above."
|
|
2637
|
+
)
|
|
2638
|
+
else:
|
|
2639
|
+
logger.info(f" Prediction range: [{np.min(y_pred):.4f}, {np.max(y_pred):.4f}], mean: {np.mean(y_pred):.4f}")
|
|
2640
|
+
|
|
2641
|
+
# 4. 保存预测结果:仅支持模型文件路径,因此输出固定保存在模型文件所在目录
|
|
2642
|
+
pred_output_dir = model_dir
|
|
2643
|
+
pred_output_dir.mkdir(parents=True, exist_ok=True)
|
|
2644
|
+
# ======================== 输出命名规范化(standardized) ========================
|
|
2645
|
+
# 预测结果统一命名为:{model_type}_predictions.tsv
|
|
2646
|
+
pred_file = pred_output_dir / f"{model_type}_predictions.tsv"
|
|
2647
|
+
|
|
2648
|
+
# 构建结果DataFrame
|
|
2649
|
+
result_df = pd.DataFrame({
|
|
2650
|
+
"sample": X_aligned.index,
|
|
2651
|
+
"prediction": y_pred
|
|
2652
|
+
})
|
|
2653
|
+
# 分类任务添加概率列
|
|
2654
|
+
if task_type == "classification" and y_prob is not None:
|
|
2655
|
+
for i in range(y_prob.shape[1]):
|
|
2656
|
+
result_df[f"prob_class_{i}"] = y_prob[:, i]
|
|
2657
|
+
|
|
2658
|
+
result_df.to_csv(pred_file, sep="\t", index=False)
|
|
2659
|
+
logger.info(f" Prediction completed. Results saved to: {pred_file}")
|
|
2660
|
+
|
|
2661
|
+
# 清理临时文件(如果是VCF转换产生的)
|
|
2662
|
+
if is_vcf_input and tmp_dir_to_clean and tmp_dir_to_clean.exists():
|
|
2663
|
+
try:
|
|
2664
|
+
shutil.rmtree(tmp_dir_to_clean, ignore_errors=True)
|
|
2665
|
+
except Exception as e:
|
|
2666
|
+
pass
|
|
2667
|
+
|
|
2668
|
+
return 0
|
|
2669
|
+
except Exception as e:
|
|
2670
|
+
logger.error(f" Prediction failed: {str(e)}", exc_info=True)
|
|
2671
|
+
return 1
|