ins-pricing 0.1.6__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.
Files changed (169) hide show
  1. ins_pricing/README.md +60 -0
  2. ins_pricing/__init__.py +102 -0
  3. ins_pricing/governance/README.md +18 -0
  4. ins_pricing/governance/__init__.py +20 -0
  5. ins_pricing/governance/approval.py +93 -0
  6. ins_pricing/governance/audit.py +37 -0
  7. ins_pricing/governance/registry.py +99 -0
  8. ins_pricing/governance/release.py +159 -0
  9. ins_pricing/modelling/BayesOpt.py +146 -0
  10. ins_pricing/modelling/BayesOpt_USAGE.md +925 -0
  11. ins_pricing/modelling/BayesOpt_entry.py +575 -0
  12. ins_pricing/modelling/BayesOpt_incremental.py +731 -0
  13. ins_pricing/modelling/Explain_Run.py +36 -0
  14. ins_pricing/modelling/Explain_entry.py +539 -0
  15. ins_pricing/modelling/Pricing_Run.py +36 -0
  16. ins_pricing/modelling/README.md +33 -0
  17. ins_pricing/modelling/__init__.py +44 -0
  18. ins_pricing/modelling/bayesopt/__init__.py +98 -0
  19. ins_pricing/modelling/bayesopt/config_preprocess.py +303 -0
  20. ins_pricing/modelling/bayesopt/core.py +1476 -0
  21. ins_pricing/modelling/bayesopt/models.py +2196 -0
  22. ins_pricing/modelling/bayesopt/trainers.py +2446 -0
  23. ins_pricing/modelling/bayesopt/utils.py +1021 -0
  24. ins_pricing/modelling/cli_common.py +136 -0
  25. ins_pricing/modelling/explain/__init__.py +55 -0
  26. ins_pricing/modelling/explain/gradients.py +334 -0
  27. ins_pricing/modelling/explain/metrics.py +176 -0
  28. ins_pricing/modelling/explain/permutation.py +155 -0
  29. ins_pricing/modelling/explain/shap_utils.py +146 -0
  30. ins_pricing/modelling/notebook_utils.py +284 -0
  31. ins_pricing/modelling/plotting/__init__.py +45 -0
  32. ins_pricing/modelling/plotting/common.py +63 -0
  33. ins_pricing/modelling/plotting/curves.py +572 -0
  34. ins_pricing/modelling/plotting/diagnostics.py +139 -0
  35. ins_pricing/modelling/plotting/geo.py +362 -0
  36. ins_pricing/modelling/plotting/importance.py +121 -0
  37. ins_pricing/modelling/run_logging.py +133 -0
  38. ins_pricing/modelling/tests/conftest.py +8 -0
  39. ins_pricing/modelling/tests/test_cross_val_generic.py +66 -0
  40. ins_pricing/modelling/tests/test_distributed_utils.py +18 -0
  41. ins_pricing/modelling/tests/test_explain.py +56 -0
  42. ins_pricing/modelling/tests/test_geo_tokens_split.py +49 -0
  43. ins_pricing/modelling/tests/test_graph_cache.py +33 -0
  44. ins_pricing/modelling/tests/test_plotting.py +63 -0
  45. ins_pricing/modelling/tests/test_plotting_library.py +150 -0
  46. ins_pricing/modelling/tests/test_preprocessor.py +48 -0
  47. ins_pricing/modelling/watchdog_run.py +211 -0
  48. ins_pricing/pricing/README.md +44 -0
  49. ins_pricing/pricing/__init__.py +27 -0
  50. ins_pricing/pricing/calibration.py +39 -0
  51. ins_pricing/pricing/data_quality.py +117 -0
  52. ins_pricing/pricing/exposure.py +85 -0
  53. ins_pricing/pricing/factors.py +91 -0
  54. ins_pricing/pricing/monitoring.py +99 -0
  55. ins_pricing/pricing/rate_table.py +78 -0
  56. ins_pricing/production/__init__.py +21 -0
  57. ins_pricing/production/drift.py +30 -0
  58. ins_pricing/production/monitoring.py +143 -0
  59. ins_pricing/production/scoring.py +40 -0
  60. ins_pricing/reporting/README.md +20 -0
  61. ins_pricing/reporting/__init__.py +11 -0
  62. ins_pricing/reporting/report_builder.py +72 -0
  63. ins_pricing/reporting/scheduler.py +45 -0
  64. ins_pricing/setup.py +41 -0
  65. ins_pricing v2/__init__.py +23 -0
  66. ins_pricing v2/governance/__init__.py +20 -0
  67. ins_pricing v2/governance/approval.py +93 -0
  68. ins_pricing v2/governance/audit.py +37 -0
  69. ins_pricing v2/governance/registry.py +99 -0
  70. ins_pricing v2/governance/release.py +159 -0
  71. ins_pricing v2/modelling/Explain_Run.py +36 -0
  72. ins_pricing v2/modelling/Pricing_Run.py +36 -0
  73. ins_pricing v2/modelling/__init__.py +151 -0
  74. ins_pricing v2/modelling/cli_common.py +141 -0
  75. ins_pricing v2/modelling/config.py +249 -0
  76. ins_pricing v2/modelling/config_preprocess.py +254 -0
  77. ins_pricing v2/modelling/core.py +741 -0
  78. ins_pricing v2/modelling/data_container.py +42 -0
  79. ins_pricing v2/modelling/explain/__init__.py +55 -0
  80. ins_pricing v2/modelling/explain/gradients.py +334 -0
  81. ins_pricing v2/modelling/explain/metrics.py +176 -0
  82. ins_pricing v2/modelling/explain/permutation.py +155 -0
  83. ins_pricing v2/modelling/explain/shap_utils.py +146 -0
  84. ins_pricing v2/modelling/features.py +215 -0
  85. ins_pricing v2/modelling/model_manager.py +148 -0
  86. ins_pricing v2/modelling/model_plotting.py +463 -0
  87. ins_pricing v2/modelling/models.py +2203 -0
  88. ins_pricing v2/modelling/notebook_utils.py +294 -0
  89. ins_pricing v2/modelling/plotting/__init__.py +45 -0
  90. ins_pricing v2/modelling/plotting/common.py +63 -0
  91. ins_pricing v2/modelling/plotting/curves.py +572 -0
  92. ins_pricing v2/modelling/plotting/diagnostics.py +139 -0
  93. ins_pricing v2/modelling/plotting/geo.py +362 -0
  94. ins_pricing v2/modelling/plotting/importance.py +121 -0
  95. ins_pricing v2/modelling/run_logging.py +133 -0
  96. ins_pricing v2/modelling/tests/conftest.py +8 -0
  97. ins_pricing v2/modelling/tests/test_cross_val_generic.py +66 -0
  98. ins_pricing v2/modelling/tests/test_distributed_utils.py +18 -0
  99. ins_pricing v2/modelling/tests/test_explain.py +56 -0
  100. ins_pricing v2/modelling/tests/test_geo_tokens_split.py +49 -0
  101. ins_pricing v2/modelling/tests/test_graph_cache.py +33 -0
  102. ins_pricing v2/modelling/tests/test_plotting.py +63 -0
  103. ins_pricing v2/modelling/tests/test_plotting_library.py +150 -0
  104. ins_pricing v2/modelling/tests/test_preprocessor.py +48 -0
  105. ins_pricing v2/modelling/trainers.py +2447 -0
  106. ins_pricing v2/modelling/utils.py +1020 -0
  107. ins_pricing v2/modelling/watchdog_run.py +211 -0
  108. ins_pricing v2/pricing/__init__.py +27 -0
  109. ins_pricing v2/pricing/calibration.py +39 -0
  110. ins_pricing v2/pricing/data_quality.py +117 -0
  111. ins_pricing v2/pricing/exposure.py +85 -0
  112. ins_pricing v2/pricing/factors.py +91 -0
  113. ins_pricing v2/pricing/monitoring.py +99 -0
  114. ins_pricing v2/pricing/rate_table.py +78 -0
  115. ins_pricing v2/production/__init__.py +21 -0
  116. ins_pricing v2/production/drift.py +30 -0
  117. ins_pricing v2/production/monitoring.py +143 -0
  118. ins_pricing v2/production/scoring.py +40 -0
  119. ins_pricing v2/reporting/__init__.py +11 -0
  120. ins_pricing v2/reporting/report_builder.py +72 -0
  121. ins_pricing v2/reporting/scheduler.py +45 -0
  122. ins_pricing v2/scripts/BayesOpt_incremental.py +722 -0
  123. ins_pricing v2/scripts/Explain_entry.py +545 -0
  124. ins_pricing v2/scripts/__init__.py +1 -0
  125. ins_pricing v2/scripts/train.py +568 -0
  126. ins_pricing v2/setup.py +55 -0
  127. ins_pricing v2/smoke_test.py +28 -0
  128. ins_pricing-0.1.6.dist-info/METADATA +78 -0
  129. ins_pricing-0.1.6.dist-info/RECORD +169 -0
  130. ins_pricing-0.1.6.dist-info/WHEEL +5 -0
  131. ins_pricing-0.1.6.dist-info/top_level.txt +4 -0
  132. user_packages/__init__.py +105 -0
  133. user_packages legacy/BayesOpt.py +5659 -0
  134. user_packages legacy/BayesOpt_entry.py +513 -0
  135. user_packages legacy/BayesOpt_incremental.py +685 -0
  136. user_packages legacy/Pricing_Run.py +36 -0
  137. user_packages legacy/Try/BayesOpt Legacy251213.py +3719 -0
  138. user_packages legacy/Try/BayesOpt Legacy251215.py +3758 -0
  139. user_packages legacy/Try/BayesOpt lagecy251201.py +3506 -0
  140. user_packages legacy/Try/BayesOpt lagecy251218.py +3992 -0
  141. user_packages legacy/Try/BayesOpt legacy.py +3280 -0
  142. user_packages legacy/Try/BayesOpt.py +838 -0
  143. user_packages legacy/Try/BayesOptAll.py +1569 -0
  144. user_packages legacy/Try/BayesOptAllPlatform.py +909 -0
  145. user_packages legacy/Try/BayesOptCPUGPU.py +1877 -0
  146. user_packages legacy/Try/BayesOptSearch.py +830 -0
  147. user_packages legacy/Try/BayesOptSearchOrigin.py +829 -0
  148. user_packages legacy/Try/BayesOptV1.py +1911 -0
  149. user_packages legacy/Try/BayesOptV10.py +2973 -0
  150. user_packages legacy/Try/BayesOptV11.py +3001 -0
  151. user_packages legacy/Try/BayesOptV12.py +3001 -0
  152. user_packages legacy/Try/BayesOptV2.py +2065 -0
  153. user_packages legacy/Try/BayesOptV3.py +2209 -0
  154. user_packages legacy/Try/BayesOptV4.py +2342 -0
  155. user_packages legacy/Try/BayesOptV5.py +2372 -0
  156. user_packages legacy/Try/BayesOptV6.py +2759 -0
  157. user_packages legacy/Try/BayesOptV7.py +2832 -0
  158. user_packages legacy/Try/BayesOptV8Codex.py +2731 -0
  159. user_packages legacy/Try/BayesOptV8Gemini.py +2614 -0
  160. user_packages legacy/Try/BayesOptV9.py +2927 -0
  161. user_packages legacy/Try/BayesOpt_entry legacy.py +313 -0
  162. user_packages legacy/Try/ModelBayesOptSearch.py +359 -0
  163. user_packages legacy/Try/ResNetBayesOptSearch.py +249 -0
  164. user_packages legacy/Try/XgbBayesOptSearch.py +121 -0
  165. user_packages legacy/Try/xgbbayesopt.py +523 -0
  166. user_packages legacy/__init__.py +19 -0
  167. user_packages legacy/cli_common.py +124 -0
  168. user_packages legacy/notebook_utils.py +228 -0
  169. user_packages legacy/watchdog_run.py +202 -0
@@ -0,0 +1,2832 @@
1
+ # 数据在CPU和GPU之间传输会带来较大开销,但可以多CUDA流同时传输数据和计算,从而实现更大数据集的操作。
2
+
3
+ import copy
4
+ import gc
5
+ import math
6
+ import os
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from re import X
10
+ from typing import Any, Dict, List, Optional
11
+ import csv
12
+
13
+ import joblib
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np # 1.26.2
16
+ import optuna # 4.3.0
17
+ import pandas as pd # 2.2.3
18
+ import shap
19
+ import statsmodels.api as sm
20
+
21
+ import torch # 版本: 1.10.1+cu111
22
+ import torch.nn as nn
23
+ import torch.nn.functional as F
24
+ import xgboost as xgb # 1.7.0
25
+
26
+ from torch.utils.data import Dataset, DataLoader, TensorDataset
27
+ from torch.cuda.amp import autocast, GradScaler
28
+ from torch.nn.utils import clip_grad_norm_
29
+ from sklearn.model_selection import ShuffleSplit, cross_val_score # 1.2.2
30
+ from sklearn.preprocessing import StandardScaler
31
+ from sklearn.metrics import log_loss, make_scorer, mean_tweedie_deviance
32
+
33
+ # =============================================================================
34
+ # Constants & utilities
35
+ # =============================================================================
36
+ EPS = 1e-8
37
+
38
+ def csv_to_dict(file_path):
39
+ with open(file_path, mode='r', encoding='utf-8') as file:
40
+ reader = csv.DictReader(file)
41
+ data = [dict(filter(lambda item: item[0] != '', row.items())) for row in reader]
42
+ return data
43
+
44
+ def ensure_parent_dir(file_path: str) -> None:
45
+ # 若目标文件所在目录不存在则自动创建
46
+ directory = os.path.dirname(file_path)
47
+ if directory:
48
+ os.makedirs(directory, exist_ok=True)
49
+
50
+
51
+ def compute_batch_size(data_size: int, learning_rate: float, batch_num: int, minimum: int) -> int:
52
+ # 按学习率和样本量给出估算 batch,再夹在 [1, data_size] 范围内
53
+ estimated = int((learning_rate / 1e-4) ** 0.5 *
54
+ (data_size / max(batch_num, 1)))
55
+ return max(1, min(data_size, max(minimum, estimated)))
56
+
57
+
58
+ # 定义在 PyTorch 环境下的 Tweedie 偏差损失函数
59
+ # 参考文档:https://scikit-learn.org/stable/modules/model_evaluation.html#mean-poisson-gamma-and-tweedie-deviances
60
+
61
+
62
+ def tweedie_loss(pred, target, p=1.5, eps=1e-6, max_clip=1e6):
63
+ # 为确保稳定性先将预测值裁剪为正数
64
+ pred_clamped = torch.clamp(pred, min=eps)
65
+ # 计算 Tweedie 偏差的各部分
66
+ if p == 1:
67
+ # 对应泊松分布
68
+ term1 = target * torch.log(target / pred_clamped + eps)
69
+ term2 = -target + pred_clamped
70
+ term3 = 0
71
+ elif p == 0:
72
+ # 对应高斯分布
73
+ term1 = 0.5 * torch.pow(target - pred_clamped, 2)
74
+ term2 = 0
75
+ term3 = 0
76
+ elif p == 2:
77
+ # 对应伽马分布
78
+ term1 = torch.log(pred_clamped / target + eps)
79
+ term2 = -target / pred_clamped + 1
80
+ term3 = 0
81
+ else:
82
+ term1 = torch.pow(target, 2 - p) / ((1 - p) * (2 - p))
83
+ term2 = target * torch.pow(pred_clamped, 1 - p) / (1 - p)
84
+ term3 = torch.pow(pred_clamped, 2 - p) / (2 - p)
85
+ # Tweedie 负对数似然(忽略常数项)
86
+ return torch.nan_to_num(2 * (term1 - term2 + term3), nan=eps, posinf=max_clip, neginf=-max_clip)
87
+
88
+ # 定义释放CUDA内存函数
89
+
90
+
91
+ def free_cuda():
92
+ print(">>> Moving all models to CPU...")
93
+ for obj in gc.get_objects():
94
+ try:
95
+ if hasattr(obj, "to") and callable(obj.to):
96
+ # 跳过 torch.device 等不可移动对象
97
+ obj.to("cpu")
98
+ except:
99
+ pass
100
+
101
+ print(">>> Deleting tensors, optimizers, dataloaders...")
102
+ gc.collect()
103
+
104
+ print(">>> Emptying CUDA cache...")
105
+ torch.cuda.empty_cache()
106
+ torch.cuda.synchronize()
107
+
108
+ print(">>> CUDA memory freed.")
109
+
110
+
111
+ # =============================================================================
112
+ # Plotting helpers
113
+ # =============================================================================
114
+
115
+ # 定义分箱函数
116
+
117
+
118
+ def split_data(data, col_nme, wgt_nme, n_bins=10):
119
+ # 避免修改原始数据帧,先创建排序后的副本
120
+ data_sorted = data.sort_values(by=col_nme, ascending=True).copy()
121
+ data_sorted['cum_weight'] = data_sorted[wgt_nme].cumsum()
122
+ w_sum = data_sorted[wgt_nme].sum()
123
+ if w_sum <= EPS:
124
+ data_sorted.loc[:, 'bins'] = 0
125
+ else:
126
+ data_sorted.loc[:, 'bins'] = np.floor(
127
+ data_sorted['cum_weight'] * float(n_bins) / w_sum
128
+ )
129
+ data_sorted.loc[(data_sorted['bins'] == n_bins), 'bins'] = n_bins - 1
130
+ return data_sorted.groupby(['bins'], observed=True).sum(numeric_only=True)
131
+
132
+ # 定义提纯曲线(Lift)绘制函数
133
+
134
+
135
+ def plot_lift_list(pred_model, w_pred_list, w_act_list,
136
+ weight_list, tgt_nme, n_bins=10,
137
+ fig_nme='Lift Chart'):
138
+ lift_data = pd.DataFrame()
139
+ lift_data.loc[:, 'pred'] = pred_model
140
+ lift_data.loc[:, 'w_pred'] = w_pred_list
141
+ lift_data.loc[:, 'act'] = w_act_list
142
+ lift_data.loc[:, 'weight'] = weight_list
143
+ plot_data = split_data(lift_data, 'pred', 'weight', n_bins)
144
+ plot_data['exp_v'] = plot_data['w_pred'] / plot_data['weight']
145
+ plot_data['act_v'] = plot_data['act'] / plot_data['weight']
146
+ plot_data.reset_index(inplace=True)
147
+ fig = plt.figure(figsize=(7, 5))
148
+ ax = fig.add_subplot(111)
149
+ ax.plot(plot_data.index, plot_data['act_v'],
150
+ label='Actual', color='red')
151
+ ax.plot(plot_data.index, plot_data['exp_v'],
152
+ label='Predicted', color='blue')
153
+ ax.set_title(
154
+ 'Lift Chart of %s' % tgt_nme, fontsize=8)
155
+ plt.xticks(plot_data.index,
156
+ plot_data.index,
157
+ rotation=90, fontsize=6)
158
+ plt.yticks(fontsize=6)
159
+ plt.legend(loc='upper left',
160
+ fontsize=5, frameon=False)
161
+ plt.margins(0.05)
162
+ ax2 = ax.twinx()
163
+ ax2.bar(plot_data.index, plot_data['weight'],
164
+ alpha=0.5, color='seagreen',
165
+ label='Earned Exposure')
166
+ plt.yticks(fontsize=6)
167
+ plt.legend(loc='upper right',
168
+ fontsize=5, frameon=False)
169
+ plt.subplots_adjust(wspace=0.3)
170
+ save_path = os.path.join(
171
+ os.getcwd(), 'plot', f'05_{tgt_nme}_{fig_nme}.png')
172
+ ensure_parent_dir(save_path)
173
+ plt.savefig(save_path, dpi=300)
174
+ plt.close(fig)
175
+
176
+ # 定义双提纯曲线绘制函数
177
+
178
+
179
+ def plot_dlift_list(pred_model_1, pred_model_2,
180
+ model_nme_1, model_nme_2,
181
+ tgt_nme,
182
+ w_list, w_act_list, n_bins=10,
183
+ fig_nme='Double Lift Chart'):
184
+ lift_data = pd.DataFrame()
185
+ lift_data.loc[:, 'pred1'] = pred_model_1
186
+ lift_data.loc[:, 'pred2'] = pred_model_2
187
+ lift_data.loc[:, 'diff_ly'] = lift_data['pred1'] / lift_data['pred2']
188
+ lift_data.loc[:, 'act'] = w_act_list
189
+ lift_data.loc[:, 'weight'] = w_list
190
+ lift_data.loc[:, 'w_pred1'] = lift_data['pred1'] * lift_data['weight']
191
+ lift_data.loc[:, 'w_pred2'] = lift_data['pred2'] * lift_data['weight']
192
+ plot_data = split_data(lift_data, 'diff_ly', 'weight', n_bins)
193
+ plot_data['exp_v1'] = plot_data['w_pred1'] / plot_data['act']
194
+ plot_data['exp_v2'] = plot_data['w_pred2'] / plot_data['act']
195
+ plot_data['act_v'] = plot_data['act']/plot_data['act']
196
+ plot_data.reset_index(inplace=True)
197
+ fig = plt.figure(figsize=(7, 5))
198
+ ax = fig.add_subplot(111)
199
+ ax.plot(plot_data.index, plot_data['act_v'],
200
+ label='Actual', color='red')
201
+ ax.plot(plot_data.index, plot_data['exp_v1'],
202
+ label=model_nme_1, color='blue')
203
+ ax.plot(plot_data.index, plot_data['exp_v2'],
204
+ label=model_nme_2, color='black')
205
+ ax.set_title(
206
+ 'Double Lift Chart of %s' % tgt_nme, fontsize=8)
207
+ plt.xticks(plot_data.index,
208
+ plot_data.index,
209
+ rotation=90, fontsize=6)
210
+ plt.xlabel('%s / %s' % (model_nme_1, model_nme_2), fontsize=6)
211
+ plt.yticks(fontsize=6)
212
+ plt.legend(loc='upper left',
213
+ fontsize=5, frameon=False)
214
+ plt.margins(0.1)
215
+ plt.subplots_adjust(bottom=0.25, top=0.95, right=0.8)
216
+ ax2 = ax.twinx()
217
+ ax2.bar(plot_data.index, plot_data['weight'],
218
+ alpha=0.5, color='seagreen',
219
+ label='Earned Exposure')
220
+ plt.yticks(fontsize=6)
221
+ plt.legend(loc='upper right',
222
+ fontsize=5, frameon=False)
223
+ plt.subplots_adjust(wspace=0.3)
224
+ save_path = os.path.join(
225
+ os.getcwd(), 'plot', f'06_{tgt_nme}_{fig_nme}.png')
226
+ ensure_parent_dir(save_path)
227
+ plt.savefig(save_path, dpi=300)
228
+ plt.close(fig)
229
+
230
+
231
+ # =============================================================================
232
+ # ResNet model & sklearn-style wrapper
233
+ # =============================================================================
234
+
235
+ # 开始定义ResNet模型结构
236
+ # 残差块:两层线性 + ReLU + 残差连接
237
+ # ResBlock 继承 nn.Module
238
+ class ResBlock(nn.Module):
239
+ def __init__(self, dim: int, dropout: float = 0.1,
240
+ use_layernorm: bool = False, residual_scale: float = 0.1
241
+ ):
242
+ super().__init__()
243
+ self.use_layernorm = use_layernorm
244
+
245
+ if use_layernorm:
246
+ Norm = nn.LayerNorm # 对最后一维做归一化
247
+ else:
248
+ def Norm(d): return nn.BatchNorm1d(d) # 保留一个开关,想试 BN 时也能用
249
+
250
+ self.norm1 = Norm(dim)
251
+ self.fc1 = nn.Linear(dim, dim, bias=True)
252
+ self.act = nn.ReLU(inplace=True)
253
+ self.dropout = nn.Dropout(dropout) if dropout > 0.0 else nn.Identity()
254
+ self.norm2 = Norm(dim)
255
+ self.fc2 = nn.Linear(dim, dim, bias=True)
256
+
257
+ # 残差缩放,防止一开始就把主干搞炸
258
+ self.res_scale = nn.Parameter(
259
+ torch.tensor(residual_scale, dtype=torch.float32)
260
+ )
261
+
262
+ def forward(self, x):
263
+ # 前置激活结构
264
+ out = self.norm1(x)
265
+ out = self.fc1(out)
266
+ out = self.act(out)
267
+ out = self.dropout(out)
268
+ out = self.norm2(out)
269
+ out = self.fc2(out)
270
+ # 残差缩放再相加
271
+ return F.relu(x + self.res_scale * out)
272
+
273
+ # ResNetSequential 继承 nn.Module,定义整个网络结构
274
+
275
+
276
+ class ResNetSequential(nn.Module):
277
+ # 输入张量形状:(batch, input_dim)
278
+ # 网络结构:全连接 + 归一化 + ReLU,再堆叠若干残差块,最后输出 Softplus
279
+
280
+ def __init__(self, input_dim: int, hidden_dim: int = 64, block_num: int = 2,
281
+ use_layernorm: bool = True, dropout: float = 0.1,
282
+ residual_scale: float = 0.1):
283
+ super(ResNetSequential, self).__init__()
284
+
285
+ self.net = nn.Sequential()
286
+ self.net.add_module('fc1', nn.Linear(input_dim, hidden_dim))
287
+
288
+ if use_layernorm:
289
+ self.net.add_module('norm1', nn.LayerNorm(hidden_dim))
290
+ else:
291
+ self.net.add_module('norm1', nn.BatchNorm1d(hidden_dim))
292
+
293
+ self.net.add_module('relu1', nn.ReLU(inplace=True))
294
+
295
+ # 多个残差块
296
+ for i in range(block_num):
297
+ self.net.add_module(
298
+ f'ResBlk_{i+1}',
299
+ ResBlock(
300
+ hidden_dim,
301
+ dropout=dropout,
302
+ use_layernorm=use_layernorm,
303
+ residual_scale=residual_scale)
304
+ )
305
+
306
+ self.net.add_module('fc_out', nn.Linear(hidden_dim, 1))
307
+ self.net.add_module('softplus', nn.Softplus())
308
+
309
+ def forward(self, x):
310
+ return self.net(x)
311
+
312
+ # 定义ResNet模型的Scikit-Learn接口类
313
+
314
+
315
+ class ResNetSklearn(nn.Module):
316
+ def __init__(self, model_nme: str, input_dim: int, hidden_dim: int = 64,
317
+ block_num: int = 2, batch_num: int = 100, epochs: int = 100,
318
+ task_type: str = 'regression',
319
+ tweedie_power: float = 1.5, learning_rate: float = 0.01, patience: int = 10,
320
+ use_layernorm: bool = True, dropout: float = 0.1,
321
+ residual_scale: float = 0.1,
322
+ use_data_parallel: bool = True):
323
+ super(ResNetSklearn, self).__init__()
324
+
325
+ self.input_dim = input_dim
326
+ self.hidden_dim = hidden_dim
327
+ self.block_num = block_num
328
+ self.batch_num = batch_num
329
+ self.epochs = epochs
330
+ self.task_type = task_type
331
+ self.model_nme = model_nme
332
+ self.learning_rate = learning_rate
333
+ self.patience = patience
334
+ self.use_layernorm = use_layernorm
335
+ self.dropout = dropout
336
+ self.residual_scale = residual_scale
337
+
338
+ # 设备选择:cuda > mps > cpu
339
+ if torch.cuda.is_available():
340
+ self.device = torch.device('cuda')
341
+ elif torch.backends.mps.is_available():
342
+ self.device = torch.device('mps')
343
+ else:
344
+ self.device = torch.device('cpu')
345
+
346
+ # Tweedie 幂指数设定
347
+ if 'f' in self.model_nme:
348
+ self.tw_power = 1
349
+ elif 's' in self.model_nme:
350
+ self.tw_power = 2
351
+ else:
352
+ self.tw_power = tweedie_power
353
+
354
+ # 搭建网络(先在 CPU 上建好)
355
+ core = ResNetSequential(
356
+ self.input_dim,
357
+ self.hidden_dim,
358
+ self.block_num,
359
+ use_layernorm=self.use_layernorm,
360
+ dropout=self.dropout,
361
+ residual_scale=self.residual_scale
362
+ )
363
+
364
+ # 如果是分类任务,替换掉最后的 Softplus
365
+ if self.task_type == 'classification':
366
+ core.net.softplus = nn.Identity()
367
+
368
+ # ===== ⭐ 多卡支持:DataParallel =====
369
+ if use_data_parallel and (self.device.type == 'cuda') and (torch.cuda.device_count() > 1):
370
+ core = nn.DataParallel(core, device_ids=list(range(torch.cuda.device_count())))
371
+ # DataParallel 会把输入 scatter 到多卡上,但“主设备”仍然是 cuda:0
372
+ self.device = torch.device('cuda')
373
+
374
+ self.resnet = core.to(self.device)
375
+
376
+ def forward(self, x):
377
+ # 处理 SHAP 的 NumPy 输入
378
+ if isinstance(x, np.ndarray):
379
+ x_tensor = torch.tensor(x, dtype=torch.float32)
380
+ else:
381
+ x_tensor = x
382
+
383
+ x_tensor = x_tensor.to(self.device)
384
+ y_pred = self.resnet(x_tensor)
385
+ return y_pred
386
+
387
+ # ---------------- 训练 ----------------
388
+
389
+ def fit(self, X_train, y_train, w_train=None,
390
+ X_val=None, y_val=None, w_val=None):
391
+
392
+ # === 1. 训练集:先留在 CPU,交给 DataLoader 批量搬运到 GPU ===
393
+ X_tensor = torch.tensor(X_train.values, dtype=torch.float32)
394
+ y_tensor = torch.tensor(
395
+ y_train.values, dtype=torch.float32).view(-1, 1)
396
+ if w_train is not None:
397
+ w_tensor = torch.tensor(
398
+ w_train.values, dtype=torch.float32).view(-1, 1)
399
+ else:
400
+ w_tensor = torch.ones_like(y_tensor)
401
+
402
+ # === 2. 验证集:先在 CPU 上构造,后续一次性搬到目标设备 ===
403
+ has_val = X_val is not None and y_val is not None
404
+ if has_val:
405
+ X_val_tensor = torch.tensor(X_val.values, dtype=torch.float32)
406
+ y_val_tensor = torch.tensor(
407
+ y_val.values, dtype=torch.float32).view(-1, 1)
408
+ if w_val is not None:
409
+ w_val_tensor = torch.tensor(
410
+ w_val.values, dtype=torch.float32).view(-1, 1)
411
+ else:
412
+ w_val_tensor = torch.ones_like(y_val_tensor)
413
+ else:
414
+ X_val_tensor = y_val_tensor = w_val_tensor = None
415
+
416
+ # === 3. 构建 DataLoader ===
417
+ dataset = TensorDataset(X_tensor, y_tensor, w_tensor)
418
+ batch_size = compute_batch_size(
419
+ data_size=len(dataset),
420
+ learning_rate=self.learning_rate,
421
+ batch_num=self.batch_num,
422
+ minimum=64
423
+ )
424
+ N = X_tensor.shape[0]
425
+
426
+ if self.device.type == 'cuda':
427
+ if N > 200_000:
428
+ base_bs = 4096
429
+ elif N > 50_000:
430
+ base_bs = 2048
431
+ else:
432
+ base_bs = 1024
433
+ else:
434
+ if N > 50_000:
435
+ base_bs = 1024
436
+ else:
437
+ base_bs = 512
438
+
439
+ # 物理 batch:显存安全为主
440
+ batch_size = min(batch_size, base_bs, N)
441
+
442
+ # ===== ⭐ 梯度累计设置 =====
443
+ # 目标“有效 batch 大小”:影响收敛稳定性
444
+ if self.device.type == 'cuda':
445
+ target_effective_bs = 8192 # 可以按需要改成 4096 / 16384
446
+ else:
447
+ target_effective_bs = 4096
448
+
449
+ accum_steps = max(1, target_effective_bs // batch_size)
450
+
451
+ dataloader = DataLoader(
452
+ dataset,
453
+ batch_size=batch_size,
454
+ shuffle=True,
455
+ num_workers=1, # 表格数据通常 0~1 个线程即可
456
+ pin_memory=(self.device.type == 'cuda')
457
+ )
458
+
459
+ # === 4. 优化器与 AMP ===
460
+ self.optimizer = torch.optim.Adam(
461
+ self.resnet.parameters(), lr=self.learning_rate)
462
+ self.scaler = GradScaler(enabled=(self.device.type == 'cuda'))
463
+
464
+ # === 5. 早停机制 ===
465
+ best_loss, patience_counter = float('inf'), 0
466
+ best_model_state = None
467
+
468
+ # 若存在验证集则一次性搬到目标设备
469
+ if has_val:
470
+ X_val_dev = X_val_tensor.to(self.device, non_blocking=True)
471
+ y_val_dev = y_val_tensor.to(self.device, non_blocking=True)
472
+ w_val_dev = w_val_tensor.to(self.device, non_blocking=True)
473
+
474
+ # === 6. 训练循环 ===
475
+ for epoch in range(1, self.epochs + 1):
476
+ self.resnet.train()
477
+ self.optimizer.zero_grad() # ⭐ 梯度在 epoch 内累积
478
+
479
+ for step, (X_batch, y_batch, w_batch) in enumerate(dataloader):
480
+
481
+ X_batch = X_batch.to(self.device, non_blocking=True)
482
+ y_batch = y_batch.to(self.device, non_blocking=True)
483
+ w_batch = w_batch.to(self.device, non_blocking=True)
484
+
485
+ with autocast(enabled=(self.device.type == 'cuda')):
486
+ y_pred = self.resnet(X_batch)
487
+
488
+ if self.task_type == 'classification':
489
+ loss_fn = nn.BCEWithLogitsLoss(reduction='none')
490
+ losses = loss_fn(y_pred, y_batch).view(-1)
491
+ else:
492
+ y_pred = torch.clamp(y_pred, min=1e-6)
493
+ losses = tweedie_loss(
494
+ y_pred, y_batch, p=self.tw_power
495
+ ).view(-1)
496
+
497
+ weighted_loss = (
498
+ losses * w_batch.view(-1)
499
+ ).sum() / torch.clamp(w_batch.sum(), min=EPS)
500
+
501
+ # ⭐ 梯度累计:loss 除以 accum_steps
502
+ loss_for_backward = weighted_loss / accum_steps
503
+
504
+ self.scaler.scale(loss_for_backward).backward()
505
+
506
+ # ⭐ 到了累积次数或者最后一个 batch,才真正 step 一次
507
+ if ((step + 1) % accum_steps == 0) or ((step + 1) == len(dataloader)):
508
+ if self.device.type == 'cuda':
509
+ self.scaler.unscale_(self.optimizer)
510
+ clip_grad_norm_(self.resnet.parameters(), max_norm=1.0)
511
+
512
+ self.scaler.step(self.optimizer)
513
+ self.scaler.update()
514
+ self.optimizer.zero_grad()
515
+
516
+ # === 7. 验证损失与早停判断 ===
517
+ if has_val:
518
+ self.resnet.eval()
519
+ with torch.no_grad(), autocast(enabled=(self.device.type == 'cuda')):
520
+ y_val_pred = self.resnet(X_val_dev)
521
+
522
+ if self.task_type == 'classification':
523
+ val_loss_fn = nn.BCEWithLogitsLoss(reduction='none')
524
+ val_loss_values = val_loss_fn(
525
+ y_val_pred, y_val_dev).view(-1)
526
+ else:
527
+ y_val_pred = torch.clamp(y_val_pred, min=1e-6)
528
+ val_loss_values = tweedie_loss(
529
+ y_val_pred, y_val_dev, p=self.tw_power
530
+ ).view(-1)
531
+
532
+ val_weighted_loss = (
533
+ val_loss_values * w_val_dev.view(-1)
534
+ ).sum() / torch.clamp(w_val_dev.sum(), min=EPS)
535
+
536
+ if val_weighted_loss < best_loss:
537
+ best_loss = val_weighted_loss
538
+ patience_counter = 0
539
+ best_model_state = copy.deepcopy(self.resnet.state_dict())
540
+ else:
541
+ patience_counter += 1
542
+
543
+ if patience_counter >= self.patience and best_model_state is not None:
544
+ self.resnet.load_state_dict(best_model_state)
545
+ break
546
+
547
+ if has_val and best_model_state is not None:
548
+ self.resnet.load_state_dict(best_model_state)
549
+
550
+ # ---------------- 预测 ----------------
551
+
552
+ def predict(self, X_test):
553
+ self.resnet.eval()
554
+ if isinstance(X_test, pd.DataFrame):
555
+ X_np = X_test.values.astype(np.float32)
556
+ else:
557
+ X_np = X_test
558
+
559
+ with torch.no_grad():
560
+ y_pred = self(X_np).cpu().numpy()
561
+
562
+ if self.task_type == 'classification':
563
+ y_pred = 1 / (1 + np.exp(-y_pred)) # Sigmoid
564
+ else:
565
+ y_pred = np.clip(y_pred, 1e-6, None)
566
+ return y_pred.flatten()
567
+
568
+ # ---------------- 设置参数 ----------------
569
+
570
+ def set_params(self, params):
571
+ for key, value in params.items():
572
+ if hasattr(self, key):
573
+ setattr(self, key, value)
574
+ else:
575
+ raise ValueError(f"Parameter {key} not found in model.")
576
+ return self
577
+
578
+
579
+ # =============================================================================
580
+ # FT-Transformer model & sklearn-style wrapper
581
+ # =============================================================================
582
+ # 开始定义FT Transformer模型结构
583
+
584
+
585
+ class FeatureTokenizer(nn.Module):
586
+ # 将数值与类别特征映射为 token,输出形状 (batch, token 数, d_model)
587
+ # 设定:
588
+ # - X_num 表示数值特征,形状 (batch, num_numeric)
589
+ # - X_cat 表示类别特征,形状 (batch, num_categorical),每列为编码后的整数标签 [0, card-1]
590
+
591
+ def __init__(self, num_numeric: int, cat_cardinalities, d_model: int):
592
+ super().__init__()
593
+
594
+ self.num_numeric = num_numeric
595
+ self.has_numeric = num_numeric > 0
596
+
597
+ if self.has_numeric:
598
+ self.num_linear = nn.Linear(num_numeric, d_model)
599
+
600
+ self.embeddings = nn.ModuleList([
601
+ nn.Embedding(card, d_model) for card in cat_cardinalities
602
+ ])
603
+
604
+ def forward(self, X_num, X_cat):
605
+ tokens = []
606
+
607
+ if self.has_numeric:
608
+ # 数值特征映射为单个 token
609
+ num_token = self.num_linear(X_num) # 形状 (batch, d_model)
610
+ tokens.append(num_token)
611
+
612
+ # 每个类别特征生成一个嵌入 token
613
+ for i, emb in enumerate(self.embeddings):
614
+ tok = emb(X_cat[:, i]) # 形状 (batch, d_model)
615
+ tokens.append(tok)
616
+
617
+ # 最终堆叠为 (batch, token 数, d_model)
618
+ x = torch.stack(tokens, dim=1)
619
+ return x
620
+
621
+ # 定义具有残差缩放的Encoder层
622
+
623
+
624
+ class ScaledTransformerEncoderLayer(nn.Module):
625
+ def __init__(self, d_model: int, nhead: int, dim_feedforward: int = 2048,
626
+ dropout: float = 0.1, residual_scale_attn: float = 1.0,
627
+ residual_scale_ffn: float = 1.0, norm_first: bool = True,
628
+ ):
629
+ super().__init__()
630
+ self.self_attn = nn.MultiheadAttention(
631
+ embed_dim=d_model,
632
+ num_heads=nhead,
633
+ dropout=dropout,
634
+ batch_first=True
635
+ )
636
+
637
+ # 前馈网络部分
638
+ self.linear1 = nn.Linear(d_model, dim_feedforward)
639
+ self.dropout = nn.Dropout(dropout)
640
+ self.linear2 = nn.Linear(dim_feedforward, d_model)
641
+
642
+ # 归一化与 Dropout
643
+ self.norm1 = nn.LayerNorm(d_model)
644
+ self.norm2 = nn.LayerNorm(d_model)
645
+ self.dropout1 = nn.Dropout(dropout)
646
+ self.dropout2 = nn.Dropout(dropout)
647
+
648
+ self.activation = nn.GELU()
649
+ # self.activation = nn.ReLU()
650
+ self.norm_first = norm_first
651
+
652
+ # 残差缩放系数
653
+ self.res_scale_attn = residual_scale_attn
654
+ self.res_scale_ffn = residual_scale_ffn
655
+
656
+ def forward(self, src, src_mask=None, src_key_padding_mask=None):
657
+ # 输入张量形状:(batch, 序列长度, d_model)
658
+ x = src
659
+
660
+ if self.norm_first:
661
+ # 先归一化再做注意力
662
+ x = x + self._sa_block(self.norm1(x), src_mask,
663
+ src_key_padding_mask)
664
+ x = x + self._ff_block(self.norm2(x))
665
+ else:
666
+ # 后归一化(一般不启用)
667
+ x = self.norm1(
668
+ x + self._sa_block(x, src_mask, src_key_padding_mask))
669
+ x = self.norm2(x + self._ff_block(x))
670
+
671
+ return x
672
+
673
+ def _sa_block(self, x, attn_mask, key_padding_mask):
674
+ # 自注意力并附带残差缩放
675
+ attn_out, _ = self.self_attn(
676
+ x, x, x,
677
+ attn_mask=attn_mask,
678
+ key_padding_mask=key_padding_mask,
679
+ need_weights=False
680
+ )
681
+ return self.res_scale_attn * self.dropout1(attn_out)
682
+
683
+ def _ff_block(self, x):
684
+ # 前馈网络并附带残差缩放
685
+ x2 = self.linear2(self.dropout(self.activation(self.linear1(x))))
686
+ return self.res_scale_ffn * self.dropout2(x2)
687
+
688
+ # 定义FT-Transformer核心模型
689
+
690
+
691
+ class FTTransformerCore(nn.Module):
692
+ # 最小可用版本的 FT-Transformer:
693
+ # - FeatureTokenizer:将数值与类别特征转换为 token
694
+ # - TransformerEncoder:捕捉特征之间的交互
695
+ # - 池化 + MLP + Softplus:保证输出为正值(适配 Tweedie/Gamma)
696
+
697
+ def __init__(self, num_numeric: int, cat_cardinalities, d_model: int = 64,
698
+ n_heads: int = 8, n_layers: int = 4, dropout: float = 0.1,
699
+ ):
700
+ super().__init__()
701
+
702
+ self.tokenizer = FeatureTokenizer(
703
+ num_numeric=num_numeric,
704
+ cat_cardinalities=cat_cardinalities,
705
+ d_model=d_model
706
+ )
707
+ scale = 1.0 / math.sqrt(n_layers) # 推荐一个默认值
708
+ encoder_layer = ScaledTransformerEncoderLayer(
709
+ d_model=d_model,
710
+ nhead=n_heads,
711
+ dim_feedforward=d_model * 4,
712
+ dropout=dropout,
713
+ residual_scale_attn=scale,
714
+ residual_scale_ffn=scale,
715
+ norm_first=True,
716
+ )
717
+ self.encoder = nn.TransformerEncoder(
718
+ encoder_layer,
719
+ num_layers=n_layers
720
+ )
721
+ self.n_layers = n_layers
722
+
723
+ self.head = nn.Sequential(
724
+ nn.LayerNorm(d_model),
725
+ nn.Linear(d_model, d_model),
726
+ nn.GELU(),
727
+ # nn.ReLU(),
728
+ nn.Linear(d_model, 1),
729
+ # nn.Softplus() # 保证输出为正,适合 Tweedie / Gamma
730
+ # 移除 Softplus,让模型输出 logits。
731
+ # 在训练和推理时根据任务类型决定是否应用 sigmoid 或 softplus。
732
+ # 对于分类,输出 logits,然后用 BCEWithLogitsLoss
733
+ # 对于回归,在推理时应用 softplus
734
+ )
735
+
736
+ def forward(self, X_num, X_cat):
737
+
738
+ # X_num: (batch, 数值特征数),float32
739
+ # X_cat: (batch, 类别特征数),long
740
+
741
+ tokens = self.tokenizer(X_num, X_cat) # 形状 (batch, token 数, d_model)
742
+ x = self.encoder(tokens) # 形状 (batch, token 数, d_model)
743
+
744
+ # 对 token 做平均池化
745
+ x = x.mean(dim=1) # 形状 (batch, d_model)
746
+
747
+ out = self.head(x) # 形状 (batch, 1),Softplus 保证为正
748
+ return out
749
+
750
+ # 定义TabularDataset类
751
+
752
+
753
+ class TabularDataset(Dataset):
754
+ def __init__(self, X_num, X_cat, y, w):
755
+
756
+ # X_num: torch.float32, 形状 (N, 数值特征数)
757
+ # X_cat: torch.long, 形状 (N, 类别特征数)
758
+ # y: torch.float32, 形状 (N, 1)
759
+ # w: torch.float32, 形状 (N, 1)
760
+
761
+ self.X_num = X_num
762
+ self.X_cat = X_cat
763
+ self.y = y
764
+ self.w = w
765
+
766
+ def __len__(self):
767
+ return self.y.shape[0]
768
+
769
+ def __getitem__(self, idx):
770
+ return (
771
+ self.X_num[idx],
772
+ self.X_cat[idx],
773
+ self.y[idx],
774
+ self.w[idx],
775
+ )
776
+
777
+ # 定义FTTransformer的Scikit-Learn接口类
778
+
779
+
780
+ class FTTransformerSklearn(nn.Module):
781
+
782
+ # sklearn 风格包装:
783
+ # - num_cols:数值特征列名列表
784
+ # - cat_cols:类别特征列名列表(需提前做标签编码,取值 [0, n_classes-1])
785
+
786
+ def __init__(self, model_nme: str, num_cols, cat_cols, d_model: int = 64, n_heads: int = 8,
787
+ n_layers: int = 4, dropout: float = 0.1, batch_num: int = 100, epochs: int = 100,
788
+ task_type: str = 'regression',
789
+ tweedie_power: float = 1.5, learning_rate: float = 1e-3, patience: int = 10,
790
+ use_data_parallel: bool = True,
791
+ ):
792
+ super().__init__()
793
+
794
+ self.model_nme = model_nme
795
+ self.num_cols = list(num_cols)
796
+ self.cat_cols = list(cat_cols)
797
+ self.d_model = d_model
798
+ self.n_heads = n_heads
799
+ self.n_layers = n_layers
800
+ self.dropout = dropout
801
+ self.batch_num = batch_num
802
+ self.epochs = epochs
803
+ self.learning_rate = learning_rate
804
+ self.task_type = task_type
805
+ self.patience = patience
806
+ if 'f' in self.model_nme:
807
+ self.tw_power = 1.0
808
+ elif 's' in self.model_nme:
809
+ self.tw_power = 2.0
810
+ else:
811
+ self.tw_power = tweedie_power
812
+ if torch.cuda.is_available():
813
+ self.device = torch.device("cuda")
814
+ elif torch.backends.mps.is_available():
815
+ self.device = torch.device("mps")
816
+ else:
817
+ self.device = torch.device("cpu")
818
+ self.cat_cardinalities = None
819
+ self.cat_categories = {}
820
+ self.ft = None
821
+ self.use_data_parallel = torch.cuda.device_count() > 1 and use_data_parallel
822
+
823
+ def _build_model(self, X_train):
824
+ num_numeric = len(self.num_cols)
825
+ cat_cardinalities = []
826
+
827
+ for col in self.cat_cols:
828
+ cats = X_train[col].astype('category')
829
+ categories = cats.cat.categories
830
+ self.cat_categories[col] = categories # 保存训练集类别全集
831
+
832
+ card = len(categories) + 1 # 多预留 1 类给“未知/缺失”
833
+ cat_cardinalities.append(card)
834
+
835
+ self.cat_cardinalities = cat_cardinalities
836
+
837
+ core = FTTransformerCore(
838
+ num_numeric=num_numeric,
839
+ cat_cardinalities=cat_cardinalities,
840
+ d_model=self.d_model,
841
+ n_heads=self.n_heads,
842
+ n_layers=self.n_layers,
843
+ dropout=self.dropout,
844
+ )
845
+ if self.use_data_parallel:
846
+ core = nn.DataParallel(core, device_ids=list(range(torch.cuda.device_count())))
847
+ self.device = torch.device("cuda")
848
+ self.ft = core.to(self.device)
849
+
850
+ def _encode_cats(self, X):
851
+ # 输入 DataFrame 至少需要包含所有类别特征列
852
+ # 返回形状 (N, 类别特征数) 的 int64 数组
853
+
854
+ if not self.cat_cols:
855
+ return np.zeros((len(X), 0), dtype='int64')
856
+
857
+ X_cat_list = []
858
+ for col in self.cat_cols:
859
+ # 使用训练阶段记录的类别全集
860
+ categories = self.cat_categories[col]
861
+ # 按固定类别构造 Categorical
862
+ cats = pd.Categorical(X[col], categories=categories)
863
+ codes = cats.codes.astype('int64', copy=True) # -1 表示未知或缺失
864
+ # 未知或缺失映射到额外的“未知”索引 len(categories)
865
+ codes[codes < 0] = len(categories)
866
+ X_cat_list.append(codes)
867
+
868
+ X_cat_np = np.stack(X_cat_list, axis=1) # 形状 (N, 类别特征数)
869
+ return X_cat_np
870
+
871
+ def fit(self, X_train, y_train, w_train=None,
872
+ X_val=None, y_val=None, w_val=None):
873
+
874
+ # 首次拟合时需要构建底层模型结构
875
+ if self.ft is None:
876
+ self._build_model(X_train)
877
+
878
+ # --- 构建训练张量(全部先放在 CPU,后续按批搬运) ---
879
+ # 数值特征
880
+ X_num_train = X_train[self.num_cols].to_numpy(
881
+ dtype=np.float32, copy=True
882
+ )
883
+ X_num_train = torch.tensor(
884
+ X_num_train,
885
+ dtype=torch.float32
886
+ )
887
+
888
+ # 类别特征
889
+ if self.cat_cols:
890
+ X_cat_train_np = self._encode_cats(X_train)
891
+ X_cat_train = torch.tensor(X_cat_train_np, dtype=torch.long)
892
+ else:
893
+ X_cat_train = torch.zeros(
894
+ (X_num_train.shape[0], 0), dtype=torch.long
895
+ )
896
+
897
+ # 目标 & 权重
898
+ y_tensor = torch.tensor(
899
+ y_train.values,
900
+ dtype=torch.float32
901
+ ).view(-1, 1)
902
+
903
+ if w_train is not None:
904
+ w_tensor = torch.tensor(
905
+ w_train.values,
906
+ dtype=torch.float32
907
+ ).view(-1, 1)
908
+ else:
909
+ w_tensor = torch.ones_like(y_tensor)
910
+
911
+ # --- 验证集张量(一次性搬到目标设备) ---
912
+ has_val = X_val is not None and y_val is not None
913
+ if has_val:
914
+ # 数值特征
915
+ X_num_val_np = X_val[self.num_cols].to_numpy(
916
+ dtype=np.float32, copy=True
917
+ )
918
+ X_num_val = torch.tensor(X_num_val_np, dtype=torch.float32)
919
+
920
+ # 类别特征
921
+ if self.cat_cols:
922
+ X_cat_val_np = self._encode_cats(X_val)
923
+ X_cat_val = torch.tensor(X_cat_val_np, dtype=torch.long)
924
+ else:
925
+ X_cat_val = torch.zeros(
926
+ (X_num_val.shape[0], 0), dtype=torch.long
927
+ )
928
+
929
+ # 目标 & 权重
930
+ y_val_np = y_val.values.astype(np.float32, copy=True)
931
+ y_val_tensor = torch.tensor(
932
+ y_val_np, dtype=torch.float32
933
+ ).view(-1, 1)
934
+
935
+ if w_val is not None:
936
+ w_val_np = w_val.values.astype(np.float32, copy=True)
937
+ w_val_tensor = torch.tensor(
938
+ w_val_np, dtype=torch.float32
939
+ ).view(-1, 1)
940
+ else:
941
+ w_val_tensor = torch.ones_like(y_val_tensor)
942
+ else:
943
+ X_num_val = X_cat_val = y_val_tensor = w_val_tensor = None
944
+
945
+ # --- 构建 DataLoader ---
946
+ dataset = TabularDataset(
947
+ X_num_train, X_cat_train, y_tensor, w_tensor
948
+ )
949
+
950
+ # 原来的 batch 估算逻辑:先给一个初始 batch_size
951
+ batch_size = compute_batch_size(
952
+ data_size=len(dataset),
953
+ learning_rate=self.learning_rate,
954
+ batch_num=self.batch_num,
955
+ minimum=64
956
+ )
957
+
958
+ N = X_num_train.shape[0]
959
+ if self.device.type == 'cuda':
960
+ if N > 200_000:
961
+ base_bs = 512
962
+ elif N > 50_000:
963
+ base_bs = 256
964
+ else:
965
+ base_bs = 128
966
+ else:
967
+ if N > 50_000:
968
+ base_bs = 256
969
+ else:
970
+ base_bs = 128
971
+
972
+ # 真实的物理 batch(显存安全为主)
973
+ batch_size = min(batch_size, base_bs, N)
974
+
975
+ # ====== ⭐ 新增:梯度累积设置 ======
976
+ # 目标“有效 batch 大小”(影响收敛稳定性)
977
+ # 可以根据经验稍微调大/调小,比如 2048 / 4096 / 8192
978
+ target_effective_bs = 4096 if self.device.type == 'cuda' else 2048
979
+ accum_steps = max(1, target_effective_bs // batch_size)
980
+
981
+ dataloader = DataLoader(
982
+ dataset,
983
+ batch_size=batch_size,
984
+ shuffle=True,
985
+ num_workers=1,
986
+ pin_memory=(self.device.type == 'cuda')
987
+ )
988
+
989
+ # --- 优化器与 AMP ---
990
+ optimizer = torch.optim.Adam(
991
+ self.ft.parameters(),
992
+ lr=self.learning_rate
993
+ )
994
+ scaler = GradScaler(enabled=(self.device.type == 'cuda'))
995
+
996
+ # --- 早停机制 ---
997
+ best_loss = float('inf')
998
+ patience_counter = 0
999
+ best_model_state = None
1000
+
1001
+ # 若存在验证集则整体迁移到目标设备
1002
+ if has_val:
1003
+ X_num_val_dev = X_num_val.to(self.device, non_blocking=True)
1004
+ X_cat_val_dev = X_cat_val.to(self.device, non_blocking=True)
1005
+ y_val_dev = y_val_tensor.to(self.device, non_blocking=True)
1006
+ w_val_dev = w_val_tensor.to(self.device, non_blocking=True)
1007
+
1008
+ # --- 训练循环 ---
1009
+ for epoch in range(1, self.epochs + 1):
1010
+ self.ft.train()
1011
+ optimizer.zero_grad() # ⭐ 梯度在 epoch 内累积,所以放在这里
1012
+
1013
+ for step, (X_num_b, X_cat_b, y_b, w_b) in enumerate(dataloader):
1014
+
1015
+ X_num_b = X_num_b.to(self.device, non_blocking=True)
1016
+ X_cat_b = X_cat_b.to(self.device, non_blocking=True)
1017
+ y_b = y_b.to(self.device, non_blocking=True)
1018
+ w_b = w_b.to(self.device, non_blocking=True)
1019
+
1020
+ with autocast(enabled=(self.device.type == 'cuda')):
1021
+ y_pred = self.ft(X_num_b, X_cat_b)
1022
+
1023
+ if self.task_type == 'classification':
1024
+ loss_fn = nn.BCEWithLogitsLoss(reduction='none')
1025
+ losses = loss_fn(y_pred, y_b).view(-1)
1026
+ else:
1027
+ # 对于回归,需要保证预测值为正
1028
+ y_pred = F.softplus(y_pred)
1029
+ y_pred = torch.clamp(y_pred, min=1e-6)
1030
+ losses = tweedie_loss(
1031
+ y_pred, y_b, p=self.tw_power
1032
+ ).view(-1)
1033
+
1034
+ weighted_loss = (
1035
+ losses * w_b.view(-1)
1036
+ ).sum() / torch.clamp(w_b.sum(), min=EPS)
1037
+
1038
+ # ⭐ 梯度累积:把 loss 平均到 accum_steps
1039
+ loss_for_backward = weighted_loss / accum_steps
1040
+
1041
+ scaler.scale(loss_for_backward).backward()
1042
+
1043
+ # ⭐ 到了累积步数或最后一个 batch,才真正 step 一次
1044
+ if ((step + 1) % accum_steps == 0) or ((step + 1) == len(dataloader)):
1045
+ # 可选:梯度裁剪
1046
+ if self.device.type == 'cuda':
1047
+ scaler.unscale_(optimizer)
1048
+ clip_grad_norm_(self.ft.parameters(), max_norm=1.0)
1049
+
1050
+ scaler.step(optimizer)
1051
+ scaler.update()
1052
+ optimizer.zero_grad()
1053
+
1054
+ # --- 验证阶段与早停判断 ---
1055
+ if has_val:
1056
+ self.ft.eval()
1057
+ with torch.no_grad(), autocast(enabled=(self.device.type == 'cuda')):
1058
+ y_val_pred = self.ft(X_num_val_dev, X_cat_val_dev)
1059
+
1060
+ if self.task_type == 'classification':
1061
+ val_loss_fn = nn.BCEWithLogitsLoss(reduction='none')
1062
+ val_losses = val_loss_fn(
1063
+ y_val_pred, y_val_dev
1064
+ ).view(-1)
1065
+ else:
1066
+ y_val_pred = F.softplus(y_val_pred)
1067
+ y_val_pred = torch.clamp(y_val_pred, min=1e-6)
1068
+ val_losses = tweedie_loss(
1069
+ y_val_pred, y_val_dev, p=self.tw_power
1070
+ ).view(-1)
1071
+
1072
+ val_weighted_loss = (
1073
+ val_losses * w_val_dev.view(-1)
1074
+ ).sum() / torch.clamp(w_val_dev.sum(), min=EPS)
1075
+
1076
+ if val_weighted_loss < best_loss:
1077
+ best_loss = val_weighted_loss
1078
+ patience_counter = 0
1079
+ best_model_state = copy.deepcopy(self.ft.state_dict())
1080
+ else:
1081
+ patience_counter += 1
1082
+
1083
+ if patience_counter >= self.patience and best_model_state is not None:
1084
+ self.ft.load_state_dict(best_model_state)
1085
+ break
1086
+
1087
+ if has_val and best_model_state is not None:
1088
+ self.ft.load_state_dict(best_model_state)
1089
+
1090
+
1091
+ def predict(self, X_test):
1092
+ # X_test 需要包含所有数值列与类别列
1093
+
1094
+ self.ft.eval()
1095
+ X_num = X_test[self.num_cols].to_numpy(dtype=np.float32, copy=True)
1096
+ X_num = torch.tensor(
1097
+ X_num,
1098
+ dtype=torch.float32
1099
+ )
1100
+ if self.cat_cols:
1101
+ X_cat_np = self._encode_cats(X_test)
1102
+ X_cat = torch.tensor(X_cat_np, dtype=torch.long)
1103
+ else:
1104
+ X_cat = torch.zeros((X_num.size(0), 0), dtype=torch.long)
1105
+
1106
+ with torch.no_grad():
1107
+ X_num = X_num.to(self.device, non_blocking=True)
1108
+ X_cat = X_cat.to(self.device, non_blocking=True)
1109
+ y_pred = self.ft(X_num, X_cat).cpu().numpy()
1110
+
1111
+ if self.task_type == 'classification':
1112
+ # 从 logits 转换为概率
1113
+ y_pred = 1 / (1 + np.exp(-y_pred))
1114
+ else:
1115
+ y_pred = np.log(1 + np.exp(y_pred)) # softplus
1116
+ y_pred = np.clip(y_pred, 1e-6, None)
1117
+ return y_pred.ravel()
1118
+
1119
+ def set_params(self, params: dict):
1120
+
1121
+ # 和 sklearn 风格保持一致。
1122
+ # 注意:对结构性参数(如 d_model/n_heads)修改后,需要重新 fit 才会生效。
1123
+
1124
+ for key, value in params.items():
1125
+ if hasattr(self, key):
1126
+ setattr(self, key, value)
1127
+ else:
1128
+ raise ValueError(f"Parameter {key} not found in model.")
1129
+ return self
1130
+
1131
+
1132
+ # ===== 基础组件与训练封装 =====================================================
1133
+
1134
+ # =============================================================================
1135
+ # Config, preprocessing, and trainer base
1136
+ # =============================================================================
1137
+ @dataclass
1138
+ class BayesOptConfig:
1139
+ model_nme: str
1140
+ resp_nme: str
1141
+ weight_nme: str
1142
+ factor_nmes: List[str]
1143
+ task_type: str = 'regression'
1144
+ binary_resp_nme: Optional[str] = None
1145
+ cate_list: Optional[List[str]] = None
1146
+ prop_test: float = 0.25
1147
+ rand_seed: Optional[int] = None
1148
+ epochs: int = 100
1149
+ use_gpu: bool = True
1150
+ use_resn_data_parallel: bool = True
1151
+ use_ft_data_parallel: bool = True
1152
+
1153
+
1154
+ class OutputManager:
1155
+ # 统一管理结果、图表与模型的输出路径
1156
+
1157
+ def __init__(self, root: Optional[str] = None, model_name: str = "model") -> None:
1158
+ self.root = Path(root or os.getcwd())
1159
+ self.model_name = model_name
1160
+ self.plot_dir = self.root / 'plot'
1161
+ self.result_dir = self.root / 'Results'
1162
+ self.model_dir = self.root / 'model'
1163
+
1164
+ def _prepare(self, path: Path) -> str:
1165
+ ensure_parent_dir(str(path))
1166
+ return str(path)
1167
+
1168
+ def plot_path(self, filename: str) -> str:
1169
+ return self._prepare(self.plot_dir / filename)
1170
+
1171
+ def result_path(self, filename: str) -> str:
1172
+ return self._prepare(self.result_dir / filename)
1173
+
1174
+ def model_path(self, filename: str) -> str:
1175
+ return self._prepare(self.model_dir / filename)
1176
+
1177
+
1178
+ class DatasetPreprocessor:
1179
+ # 为各训练器准备通用的训练/测试数据视图
1180
+
1181
+ def __init__(self, train_df: pd.DataFrame, test_df: pd.DataFrame,
1182
+ config: BayesOptConfig) -> None:
1183
+ self.config = config
1184
+ self.train_data = train_df.copy(deep=True)
1185
+ self.test_data = test_df.copy(deep=True)
1186
+ self.num_features: List[str] = []
1187
+ self.train_oht_scl_data: Optional[pd.DataFrame] = None
1188
+ self.test_oht_scl_data: Optional[pd.DataFrame] = None
1189
+ self.var_nmes: List[str] = []
1190
+ self.cat_categories_for_shap: Dict[str, List[Any]] = {}
1191
+
1192
+ def run(self) -> "DatasetPreprocessor":
1193
+ cfg = self.config
1194
+ # 预先计算加权实际值,后续画图、校验都依赖该字段
1195
+ self.train_data.loc[:, 'w_act'] = self.train_data[cfg.resp_nme] * \
1196
+ self.train_data[cfg.weight_nme]
1197
+ self.test_data.loc[:, 'w_act'] = self.test_data[cfg.resp_nme] * \
1198
+ self.test_data[cfg.weight_nme]
1199
+ if cfg.binary_resp_nme:
1200
+ self.train_data.loc[:, 'w_binary_act'] = self.train_data[cfg.binary_resp_nme] * \
1201
+ self.train_data[cfg.weight_nme]
1202
+ self.test_data.loc[:, 'w_binary_act'] = self.test_data[cfg.binary_resp_nme] * \
1203
+ self.test_data[cfg.weight_nme]
1204
+ # 高分位裁剪用来吸收离群值;若删除会导致极端点主导损失
1205
+ q99 = self.train_data[cfg.resp_nme].quantile(0.999)
1206
+ self.train_data[cfg.resp_nme] = self.train_data[cfg.resp_nme].clip(
1207
+ upper=q99)
1208
+ cate_list = list(cfg.cate_list or [])
1209
+ if cate_list:
1210
+ for cate in cate_list:
1211
+ self.train_data[cate] = self.train_data[cate].astype(
1212
+ 'category')
1213
+ self.test_data[cate] = self.test_data[cate].astype('category')
1214
+ cats = self.train_data[cate].cat.categories
1215
+ self.cat_categories_for_shap[cate] = list(cats)
1216
+ self.num_features = [
1217
+ nme for nme in cfg.factor_nmes if nme not in cate_list]
1218
+ train_oht = self.train_data[cfg.factor_nmes +
1219
+ [cfg.weight_nme] + [cfg.resp_nme]].copy()
1220
+ test_oht = self.test_data[cfg.factor_nmes +
1221
+ [cfg.weight_nme] + [cfg.resp_nme]].copy()
1222
+ train_oht = pd.get_dummies(
1223
+ train_oht,
1224
+ columns=cate_list,
1225
+ drop_first=True,
1226
+ dtype=np.int8
1227
+ )
1228
+ test_oht = pd.get_dummies(
1229
+ test_oht,
1230
+ columns=cate_list,
1231
+ drop_first=True,
1232
+ dtype=np.int8
1233
+ )
1234
+ for num_chr in self.num_features:
1235
+ # 逐列标准化保障每个特征在同一量级,否则神经网络会难以收敛
1236
+ scaler = StandardScaler()
1237
+ train_oht[num_chr] = scaler.fit_transform(
1238
+ train_oht[num_chr].values.reshape(-1, 1))
1239
+ test_oht[num_chr] = scaler.transform(
1240
+ test_oht[num_chr].values.reshape(-1, 1))
1241
+ # reindex 时将缺失的哑变量列补零,避免测试集列数与训练集不一致
1242
+ test_oht = test_oht.reindex(columns=train_oht.columns, fill_value=0)
1243
+ self.train_oht_scl_data = train_oht
1244
+ self.test_oht_scl_data = test_oht
1245
+ self.var_nmes = list(
1246
+ set(list(train_oht.columns)) - set([cfg.weight_nme, cfg.resp_nme])
1247
+ )
1248
+ return self
1249
+
1250
+ # =============================================================================
1251
+ # Trainers
1252
+ # =============================================================================
1253
+
1254
+
1255
+ class TrainerBase:
1256
+ def __init__(self, context: "BayesOptModel", label: str) -> None:
1257
+ self.ctx = context
1258
+ self.label = label
1259
+
1260
+ @property
1261
+ def config(self) -> BayesOptConfig:
1262
+ return self.ctx.config
1263
+
1264
+ @property
1265
+ def output(self) -> OutputManager:
1266
+ return self.ctx.output_manager
1267
+
1268
+ def tune(self, max_evals: int) -> None: # pragma: no cover 子类会覆盖
1269
+ raise NotImplementedError
1270
+
1271
+ def train(self) -> None: # pragma: no cover 子类会覆盖
1272
+ raise NotImplementedError
1273
+
1274
+ def save(self) -> None:
1275
+ pass
1276
+
1277
+ def load(self) -> None:
1278
+ pass
1279
+
1280
+
1281
+ class XGBTrainer(TrainerBase):
1282
+ def __init__(self, context: "BayesOptModel") -> None:
1283
+ super().__init__(context, 'Xgboost')
1284
+ self.model: Optional[xgb.XGBRegressor] = None
1285
+ self.best_params: Optional[Dict[str, Any]] = None
1286
+ self.best_trial = None
1287
+
1288
+ def _build_estimator(self) -> xgb.XGBRegressor:
1289
+ params = dict(
1290
+ objective=self.ctx.obj,
1291
+ random_state=self.ctx.rand_seed,
1292
+ subsample=0.9,
1293
+ tree_method='gpu_hist' if self.ctx.use_gpu else 'hist',
1294
+ enable_categorical=True,
1295
+ predictor='gpu_predictor' if self.ctx.use_gpu else 'cpu_predictor'
1296
+ )
1297
+ if self.ctx.use_gpu:
1298
+ params['gpu_id'] = 0
1299
+ return xgb.XGBRegressor(**params)
1300
+
1301
+ def cross_val(self, trial: optuna.trial.Trial) -> float:
1302
+ learning_rate = trial.suggest_float(
1303
+ 'learning_rate', 1e-5, 1e-1, log=True)
1304
+ gamma = trial.suggest_float('gamma', 0, 10000)
1305
+ max_depth = trial.suggest_int('max_depth', 3, 25)
1306
+ n_estimators = trial.suggest_int('n_estimators', 10, 500, step=10)
1307
+ min_child_weight = trial.suggest_int(
1308
+ 'min_child_weight', 100, 10000, step=100)
1309
+ reg_alpha = trial.suggest_float('reg_alpha', 1e-10, 1, log=True)
1310
+ reg_lambda = trial.suggest_float('reg_lambda', 1e-10, 1, log=True)
1311
+ if self.ctx.obj == 'reg:tweedie':
1312
+ tweedie_variance_power = trial.suggest_float(
1313
+ 'tweedie_variance_power', 1, 2)
1314
+ elif self.ctx.obj == 'count:poisson':
1315
+ tweedie_variance_power = 1
1316
+ elif self.ctx.obj == 'reg:gamma':
1317
+ tweedie_variance_power = 2
1318
+ else:
1319
+ tweedie_variance_power = 1.5
1320
+ clf = self._build_estimator()
1321
+ params = {
1322
+ 'learning_rate': learning_rate,
1323
+ 'gamma': gamma,
1324
+ 'max_depth': max_depth,
1325
+ 'n_estimators': n_estimators,
1326
+ 'min_child_weight': min_child_weight,
1327
+ 'reg_alpha': reg_alpha,
1328
+ 'reg_lambda': reg_lambda
1329
+ }
1330
+ if self.ctx.obj == 'reg:tweedie':
1331
+ params['tweedie_variance_power'] = tweedie_variance_power
1332
+ clf.set_params(**params)
1333
+ n_jobs = 1 if self.ctx.use_gpu else int(1 / self.ctx.prop_test)
1334
+ acc = cross_val_score(
1335
+ clf,
1336
+ self.ctx.train_data[self.ctx.factor_nmes],
1337
+ self.ctx.train_data[self.ctx.resp_nme].values,
1338
+ fit_params=self.ctx.fit_params,
1339
+ cv=self.ctx.cv,
1340
+ scoring=make_scorer(
1341
+ mean_tweedie_deviance,
1342
+ power=tweedie_variance_power,
1343
+ greater_is_better=False),
1344
+ error_score='raise',
1345
+ n_jobs=n_jobs
1346
+ ).mean()
1347
+ return -acc
1348
+
1349
+ def tune(self, max_evals: int = 100) -> None:
1350
+ study = optuna.create_study(
1351
+ direction='minimize',
1352
+ sampler=optuna.samplers.TPESampler(seed=self.ctx.rand_seed)
1353
+ )
1354
+ study.optimize(self.cross_val, n_trials=max_evals)
1355
+ self.best_params = study.best_params
1356
+ self.best_trial = study.best_trial
1357
+ params_path = self.output.result_path(
1358
+ f'{self.ctx.model_nme}_bestparams_xgb.csv'
1359
+ )
1360
+ pd.DataFrame(self.best_params, index=[0]).to_csv(params_path)
1361
+
1362
+ def train(self) -> None:
1363
+ if not self.best_params:
1364
+ raise RuntimeError('请先运行 tune() 以获得 XGB 最优参数。')
1365
+ self.model = self._build_estimator()
1366
+ self.model.set_params(**self.best_params)
1367
+ self.model.fit(self.ctx.train_data[self.ctx.factor_nmes],
1368
+ self.ctx.train_data[self.ctx.resp_nme].values,
1369
+ **self.ctx.fit_params)
1370
+ self.ctx.model_label += [self.label]
1371
+ self.ctx.train_data['pred_xgb'] = self.model.predict(
1372
+ self.ctx.train_data[self.ctx.factor_nmes])
1373
+ self.ctx.test_data['pred_xgb'] = self.model.predict(
1374
+ self.ctx.test_data[self.ctx.factor_nmes])
1375
+ self.ctx.train_data.loc[:, 'w_pred_xgb'] = self.ctx.train_data['pred_xgb'] * \
1376
+ self.ctx.train_data[self.ctx.weight_nme]
1377
+ self.ctx.test_data.loc[:, 'w_pred_xgb'] = self.ctx.test_data['pred_xgb'] * \
1378
+ self.ctx.test_data[self.ctx.weight_nme]
1379
+ self.ctx.xgb_best = self.model
1380
+
1381
+ def save(self) -> None:
1382
+ if self.model is not None:
1383
+ joblib.dump(self.model, self.output.model_path(
1384
+ f'01_{self.ctx.model_nme}_Xgboost.pkl'))
1385
+
1386
+ def load(self) -> None:
1387
+ path = self.output.model_path(
1388
+ f'01_{self.ctx.model_nme}_Xgboost.pkl')
1389
+ if os.path.exists(path):
1390
+ self.model = joblib.load(path)
1391
+ self.ctx.xgb_best = self.model
1392
+ else:
1393
+ print(f"[load_model] Warning: 未找到 Xgboost 模型文件:{path}")
1394
+
1395
+
1396
+ class GLMTrainer(TrainerBase):
1397
+ def __init__(self, context: "BayesOptModel") -> None:
1398
+ super().__init__(context, 'GLM')
1399
+ self.model = None
1400
+ self.best_params: Optional[Dict[str, Any]] = None
1401
+ self.best_trial = None
1402
+
1403
+ def _select_family(self, tweedie_power: Optional[float] = None):
1404
+ if self.ctx.task_type == 'classification':
1405
+ return sm.families.Binomial()
1406
+ if self.ctx.obj == 'count:poisson':
1407
+ return sm.families.Poisson()
1408
+ if self.ctx.obj == 'reg:gamma':
1409
+ return sm.families.Gamma()
1410
+ power = tweedie_power if tweedie_power is not None else 1.5
1411
+ return sm.families.Tweedie(var_power=power, link=sm.families.links.log())
1412
+
1413
+ def _prepare_design(self, data: pd.DataFrame) -> pd.DataFrame:
1414
+ # 为 statsmodels 添加截距项
1415
+ X = data[self.ctx.var_nmes]
1416
+ return sm.add_constant(X, has_constant='add')
1417
+
1418
+ def _metric_power(self, family, tweedie_power: Optional[float]) -> float:
1419
+ if isinstance(family, sm.families.Poisson):
1420
+ return 1.0
1421
+ if isinstance(family, sm.families.Gamma):
1422
+ return 2.0
1423
+ if isinstance(family, sm.families.Tweedie):
1424
+ return tweedie_power if tweedie_power is not None else getattr(family, 'var_power', 1.5)
1425
+ return 1.5
1426
+
1427
+ def cross_val(self, trial: optuna.trial.Trial) -> float:
1428
+ alpha = trial.suggest_float('alpha', 1e-6, 1e2, log=True)
1429
+ l1_ratio = trial.suggest_float('l1_ratio', 0.0, 1.0)
1430
+ tweedie_power = None
1431
+ if self.ctx.task_type == 'regression' and self.ctx.obj == 'reg:tweedie':
1432
+ tweedie_power = trial.suggest_float('tweedie_power', 1.01, 1.99)
1433
+
1434
+ X_all = self._prepare_design(self.ctx.train_oht_scl_data)
1435
+ y_all = self.ctx.train_oht_scl_data[self.ctx.resp_nme]
1436
+ w_all = self.ctx.train_oht_scl_data[self.ctx.weight_nme]
1437
+
1438
+ scores = []
1439
+ for train_idx, val_idx in self.ctx.cv.split(X_all):
1440
+ X_train, X_val = X_all.iloc[train_idx], X_all.iloc[val_idx]
1441
+ y_train, y_val = y_all.iloc[train_idx], y_all.iloc[val_idx]
1442
+ w_train, w_val = w_all.iloc[train_idx], w_all.iloc[val_idx]
1443
+
1444
+ family = self._select_family(tweedie_power)
1445
+ glm = sm.GLM(y_train, X_train, family=family,
1446
+ freq_weights=w_train)
1447
+ result = glm.fit_regularized(
1448
+ alpha=alpha, L1_wt=l1_ratio, maxiter=200)
1449
+
1450
+ y_pred = result.predict(X_val)
1451
+ if self.ctx.task_type == 'classification':
1452
+ y_pred = np.clip(y_pred, EPS, 1 - EPS)
1453
+ fold_score = log_loss(
1454
+ y_val, y_pred, sample_weight=w_val)
1455
+ else:
1456
+ y_pred = np.maximum(y_pred, EPS)
1457
+ fold_score = mean_tweedie_deviance(
1458
+ y_val,
1459
+ y_pred,
1460
+ sample_weight=w_val,
1461
+ power=self._metric_power(family, tweedie_power)
1462
+ )
1463
+ scores.append(fold_score)
1464
+
1465
+ return float(np.mean(scores))
1466
+
1467
+ def tune(self, max_evals: int = 50) -> None:
1468
+ study = optuna.create_study(
1469
+ direction='minimize', sampler=optuna.samplers.TPESampler(seed=self.ctx.rand_seed))
1470
+ study.optimize(self.cross_val, n_trials=max_evals)
1471
+ self.best_params = study.best_params
1472
+ self.best_trial = study.best_trial
1473
+ params_path = self.output.result_path(
1474
+ f'{self.ctx.model_nme}_bestparams_glm.csv')
1475
+ pd.DataFrame(self.best_params, index=[0]).to_csv(params_path)
1476
+
1477
+ def train(self) -> None:
1478
+ if not self.best_params:
1479
+ raise RuntimeError('请先运行 tune() 以获得 GLM 最优参数。')
1480
+ tweedie_power = self.best_params.get('tweedie_power')
1481
+ family = self._select_family(tweedie_power)
1482
+
1483
+ X_train = self._prepare_design(self.ctx.train_oht_scl_data)
1484
+ y_train = self.ctx.train_oht_scl_data[self.ctx.resp_nme]
1485
+ w_train = self.ctx.train_oht_scl_data[self.ctx.weight_nme]
1486
+
1487
+ glm = sm.GLM(y_train, X_train, family=family,
1488
+ freq_weights=w_train)
1489
+ self.model = glm.fit_regularized(
1490
+ alpha=self.best_params['alpha'],
1491
+ L1_wt=self.best_params['l1_ratio'],
1492
+ maxiter=300
1493
+ )
1494
+
1495
+ self.ctx.glm_best = self.model
1496
+ self.ctx.model_label += [self.label]
1497
+ self._attach_predictions(tweedie_power)
1498
+
1499
+ def _attach_predictions(self, tweedie_power: Optional[float]) -> None:
1500
+ for src, target in (
1501
+ (self.ctx.train_oht_scl_data, self.ctx.train_data),
1502
+ (self.ctx.test_oht_scl_data, self.ctx.test_data),
1503
+ ):
1504
+ design = self._prepare_design(src)
1505
+ preds = self.model.predict(design)
1506
+ if self.ctx.task_type == 'classification':
1507
+ preds = np.clip(preds, EPS, 1 - EPS)
1508
+ else:
1509
+ preds = np.maximum(preds, EPS)
1510
+ target['pred_glm'] = preds
1511
+ target['w_pred_glm'] = target['pred_glm'] * \
1512
+ target[self.ctx.weight_nme]
1513
+
1514
+ def save(self) -> None:
1515
+ if self.model is not None:
1516
+ joblib.dump(self.model, self.output.model_path(
1517
+ f'01_{self.ctx.model_nme}_GLM.pkl'))
1518
+
1519
+ def load(self) -> None:
1520
+ path = self.output.model_path(
1521
+ f'01_{self.ctx.model_nme}_GLM.pkl')
1522
+ if os.path.exists(path):
1523
+ self.model = joblib.load(path)
1524
+ self.ctx.glm_best = self.model
1525
+ else:
1526
+ print(f"[load_model] Warning: 未找到 GLM 模型文件:{path}")
1527
+
1528
+
1529
+ class ResNetTrainer(TrainerBase):
1530
+ def __init__(self, context: "BayesOptModel") -> None:
1531
+ if context.task_type == 'classification':
1532
+ super().__init__(context, 'ResNetClassifier')
1533
+ else:
1534
+ super().__init__(context, 'ResNet')
1535
+ self.model: Optional[ResNetSklearn] = None
1536
+ self.best_params: Optional[Dict[str, Any]] = None
1537
+ self.best_trial = None
1538
+
1539
+ # ========= 交叉验证(BayesOpt 用) =========
1540
+ def cross_val(self, trial: optuna.trial.Trial) -> float:
1541
+ """
1542
+ 对 ResNet 做交叉验证。
1543
+ 为了防止显存 OOM:
1544
+ - 每个 fold 独立创建一个 ResNetSklearn
1545
+ - fold 结束就把模型挪到 CPU + 删除 + gc + empty_cache
1546
+ - 可选:BayesOpt 阶段只用训练集子样本
1547
+ """
1548
+
1549
+ # 1. 超参空间(基本沿用你之前的设定)
1550
+ learning_rate = trial.suggest_float(
1551
+ 'learning_rate', 1e-6, 1e-2, log=True
1552
+ )
1553
+ # hidden_dim = trial.suggest_int('hidden_dim', 32, 256, step=32) # 不宜过大
1554
+ hidden_dim = trial.suggest_int('hidden_dim', 8, 32, step=2)
1555
+ block_num = trial.suggest_int('block_num', 2, 10)
1556
+ # batch_num = trial.suggest_int(
1557
+ # 'batch_num',
1558
+ # 10 if self.ctx.obj == 'reg:gamma' else 100,
1559
+ # 100 if self.ctx.obj == 'reg:gamma' else 1000,
1560
+ # step=10 if self.ctx.obj == 'reg:gamma' else 100
1561
+ # )
1562
+
1563
+ if self.ctx.task_type == 'regression':
1564
+ if self.ctx.obj == 'reg:tweedie':
1565
+ tw_power = trial.suggest_float('tw_power', 1.0, 2.0)
1566
+ elif self.ctx.obj == 'count:poisson':
1567
+ tw_power = 1.0
1568
+ elif self.ctx.obj == 'reg:gamma':
1569
+ tw_power = 2.0
1570
+ else:
1571
+ tw_power = 1.5
1572
+ else: # classification
1573
+ tw_power = None # Not used
1574
+
1575
+ fold_losses = []
1576
+
1577
+ # 2. (可选)BayesOpt 只在子样本上做 CV,减轻显存 & 时间压力
1578
+ data_for_cv = self.ctx.train_oht_scl_data
1579
+ max_rows_for_resnet_bo = min(100000, int(
1580
+ len(data_for_cv)/5)) # 你可以按 A30 情况调小,比如 50_000
1581
+ if len(data_for_cv) > max_rows_for_resnet_bo:
1582
+ data_for_cv = data_for_cv.sample(
1583
+ max_rows_for_resnet_bo,
1584
+ random_state=self.ctx.rand_seed
1585
+ )
1586
+
1587
+ X_all = data_for_cv[self.ctx.var_nmes]
1588
+ y_all = data_for_cv[self.ctx.resp_nme]
1589
+ w_all = data_for_cv[self.ctx.weight_nme]
1590
+
1591
+ # 用局部 ShuffleSplit,避免子样本时索引不一致
1592
+ cv_local = ShuffleSplit(
1593
+ n_splits=int(1 / self.ctx.prop_test),
1594
+ test_size=self.ctx.prop_test,
1595
+ random_state=self.ctx.rand_seed
1596
+ )
1597
+
1598
+ for fold, (train_idx, val_idx) in enumerate(cv_local.split(X_all)):
1599
+ X_train_fold = X_all.iloc[train_idx]
1600
+ y_train_fold = y_all.iloc[train_idx]
1601
+ w_train_fold = w_all.iloc[train_idx]
1602
+
1603
+ X_val_fold = X_all.iloc[val_idx]
1604
+ y_val_fold = y_all.iloc[val_idx]
1605
+ w_val_fold = w_all.iloc[val_idx]
1606
+
1607
+ # 3. 每个 fold 创建一个临时 ResNet 模型
1608
+ cv_net = ResNetSklearn(
1609
+ model_nme=self.ctx.model_nme,
1610
+ input_dim=X_all.shape[1],
1611
+ hidden_dim=hidden_dim,
1612
+ block_num=block_num,
1613
+ task_type=self.ctx.task_type,
1614
+ # batch_num=batch_num,
1615
+ epochs=self.ctx.epochs,
1616
+ tweedie_power=tw_power,
1617
+ learning_rate=learning_rate,
1618
+ patience=5,
1619
+ use_layernorm=True,
1620
+ dropout=0.1,
1621
+ residual_scale=0.1,
1622
+ use_data_parallel=self.ctx.config.use_resn_data_parallel
1623
+ )
1624
+
1625
+ try:
1626
+ # 4. 训练(内部仍然用你自己的 tweedie_loss)
1627
+ cv_net.fit(
1628
+ X_train_fold,
1629
+ y_train_fold,
1630
+ w_train_fold,
1631
+ X_val_fold,
1632
+ y_val_fold,
1633
+ w_val_fold
1634
+ )
1635
+
1636
+ # 5. 验证集预测
1637
+ y_pred_fold = cv_net.predict(X_val_fold)
1638
+
1639
+ # 6. 评估:Tweedie deviance(评估用,训练 loss 不动)
1640
+ if self.ctx.task_type == 'regression':
1641
+ loss = mean_tweedie_deviance(
1642
+ y_val_fold,
1643
+ y_pred_fold,
1644
+ sample_weight=w_val_fold,
1645
+ power=tw_power
1646
+ )
1647
+ else: # classification
1648
+ from sklearn.metrics import log_loss
1649
+ loss = log_loss(
1650
+ y_val_fold,
1651
+ y_pred_fold,
1652
+ sample_weight=w_val_fold,
1653
+ )
1654
+ fold_losses.append(loss)
1655
+ finally:
1656
+ # 7. ★ 每个 fold 结束后释放 GPU 资源 ★
1657
+ try:
1658
+ if hasattr(cv_net, "resnet"):
1659
+ cv_net.resnet.to("cpu")
1660
+ except Exception:
1661
+ pass
1662
+ del cv_net
1663
+ gc.collect()
1664
+ if torch.cuda.is_available():
1665
+ torch.cuda.empty_cache()
1666
+
1667
+ return np.mean(fold_losses)
1668
+
1669
+ # ========= Optuna 调参 =========
1670
+ def tune(self, max_evals: int = 50) -> None:
1671
+ """
1672
+ 使用 Optuna 对 ResNet 做贝叶斯优化。
1673
+ 每个 trial 完成以后再做一次全局的显存清理。
1674
+ """
1675
+ def objective(trial: optuna.trial.Trial) -> float:
1676
+ result = self.cross_val(trial)
1677
+ # trial 级别兜底清理
1678
+ gc.collect()
1679
+ if torch.cuda.is_available():
1680
+ torch.cuda.empty_cache()
1681
+ return result
1682
+
1683
+ study = optuna.create_study(
1684
+ direction='minimize',
1685
+ sampler=optuna.samplers.TPESampler(seed=self.ctx.rand_seed)
1686
+ )
1687
+ study.optimize(objective, n_trials=max_evals)
1688
+
1689
+ self.best_params = study.best_params
1690
+ self.best_trial = study.best_trial
1691
+
1692
+ params_path = self.output.result_path(
1693
+ f'{self.ctx.model_nme}_bestparams_resn.csv'
1694
+ )
1695
+ pd.DataFrame(self.best_params, index=[0]).to_csv(params_path)
1696
+
1697
+ # ========= 用最优超参训练最终 ResNet =========
1698
+ def train(self) -> None:
1699
+ if not self.best_params:
1700
+ raise RuntimeError('请先运行 tune() 以获得 ResNet 最优参数。')
1701
+
1702
+ self.model = ResNetSklearn(
1703
+ model_nme=self.ctx.model_nme,
1704
+ input_dim=self.ctx.train_oht_scl_data[self.ctx.var_nmes].shape[1],
1705
+ task_type=self.ctx.task_type,
1706
+ use_data_parallel=self.ctx.config.use_resn_data_parallel
1707
+ )
1708
+ self.model.set_params(self.best_params)
1709
+
1710
+ # 在全量 one-hot + 标准化数据上训练最终模型
1711
+ self.model.fit(
1712
+ self.ctx.train_oht_scl_data[self.ctx.var_nmes],
1713
+ self.ctx.train_oht_scl_data[self.ctx.resp_nme],
1714
+ self.ctx.train_oht_scl_data[self.ctx.weight_nme]
1715
+ )
1716
+
1717
+ # 记录标签
1718
+ self.ctx.model_label += [self.label]
1719
+
1720
+ # 训练集 / 测试集预测
1721
+ self.ctx.train_data['pred_resn'] = self.model.predict(
1722
+ self.ctx.train_oht_scl_data[self.ctx.var_nmes]
1723
+ )
1724
+ self.ctx.test_data['pred_resn'] = self.model.predict(
1725
+ self.ctx.test_oht_scl_data[self.ctx.var_nmes]
1726
+ )
1727
+
1728
+ # 加权赔付
1729
+ self.ctx.train_data.loc[:, 'w_pred_resn'] = (
1730
+ self.ctx.train_data['pred_resn'] *
1731
+ self.ctx.train_data[self.ctx.weight_nme]
1732
+ )
1733
+ self.ctx.test_data.loc[:, 'w_pred_resn'] = (
1734
+ self.ctx.test_data['pred_resn'] *
1735
+ self.ctx.test_data[self.ctx.weight_nme]
1736
+ )
1737
+
1738
+ # 方便外部调用
1739
+ self.ctx.resn_best = self.model
1740
+
1741
+ # ========= 保存 / 加载 =========
1742
+ def save(self) -> None:
1743
+ """
1744
+ 只保存 ResNet 的 state_dict(轻量,不含优化器)。
1745
+ """
1746
+ if self.model is not None:
1747
+ path = self.output.model_path(
1748
+ f'01_{self.ctx.model_nme}_ResNet.pth'
1749
+ )
1750
+ torch.save(self.model.resnet.state_dict(), path)
1751
+
1752
+ def load(self) -> None:
1753
+ """
1754
+ 从文件加载 ResNet 模型到合适的 device。
1755
+ """
1756
+ path = self.output.model_path(
1757
+ f'01_{self.ctx.model_nme}_ResNet.pth'
1758
+ )
1759
+ if os.path.exists(path):
1760
+ resn_loaded = ResNetSklearn(
1761
+ model_nme=self.ctx.model_nme,
1762
+ input_dim=self.ctx.train_oht_scl_data[self.ctx.var_nmes].shape[1],
1763
+ task_type=self.ctx.task_type,
1764
+ use_data_parallel=self.ctx.config.use_resn_data_parallel
1765
+ )
1766
+ state_dict = torch.load(path, map_location='cpu')
1767
+ resn_loaded.resnet.load_state_dict(state_dict)
1768
+
1769
+ # 根据当前环境设置 device
1770
+ if torch.cuda.is_available():
1771
+ resn_loaded.device = torch.device('cuda')
1772
+ elif torch.backends.mps.is_available():
1773
+ resn_loaded.device = torch.device('mps')
1774
+ else:
1775
+ resn_loaded.device = torch.device('cpu')
1776
+
1777
+ resn_loaded.resnet.to(resn_loaded.device)
1778
+ self.model = resn_loaded
1779
+ self.ctx.resn_best = self.model
1780
+ else:
1781
+ print(f"[ResNetTrainer.load] 未找到模型文件:{path}")
1782
+
1783
+
1784
+ class FTTrainer(TrainerBase):
1785
+ def __init__(self, context: "BayesOptModel") -> None:
1786
+ if context.task_type == 'classification':
1787
+ super().__init__(context, 'FTTransformerClassifier')
1788
+ else:
1789
+ super().__init__(context, 'FTTransformer')
1790
+ self.model: Optional[FTTransformerSklearn] = None
1791
+ self.best_params: Optional[Dict[str, Any]] = None
1792
+ self.best_trial = None
1793
+
1794
+ def cross_val(self, trial: optuna.trial.Trial) -> float:
1795
+ """
1796
+ 对 FT-Transformer 做交叉验证。
1797
+ 这里是显存最容易爆的地方,所以加入了:
1798
+ - 较保守的超参搜索空间
1799
+ - 每个 fold 结束后强制释放 GPU 显存
1800
+ """
1801
+ # 超参空间适当缩小一点,避免特别大的模型
1802
+ learning_rate = trial.suggest_float(
1803
+ 'learning_rate', 1e-5, 5e-4, log=True
1804
+ )
1805
+ d_model = trial.suggest_int('d_model', 32, 256, step=32)
1806
+ # n_heads = trial.suggest_categorical('n_heads', [2, 4]) 避免欠拟合
1807
+ n_heads = trial.suggest_categorical('n_heads', [2, 4, 8])
1808
+ # n_layers = trial.suggest_int('n_layers', 2, 4) 避免欠拟合
1809
+ n_layers = trial.suggest_int('n_layers', 2, 8)
1810
+ dropout = trial.suggest_float('dropout', 0.0, 0.2)
1811
+ # batch_num = trial.suggest_int(
1812
+ # 'batch_num',
1813
+ # 5 if self.ctx.obj == 'reg:gamma' else 10,
1814
+ # 10 if self.ctx.obj == 'reg:gamma' else 50,
1815
+ # step=1 if self.ctx.obj == 'reg:gamma' else 10
1816
+ # )
1817
+
1818
+ if self.ctx.task_type == 'regression':
1819
+ if self.ctx.obj == 'reg:tweedie':
1820
+ tw_power = trial.suggest_float('tw_power', 1.0, 2.0)
1821
+ elif self.ctx.obj == 'count:poisson':
1822
+ tw_power = 1.0
1823
+ elif self.ctx.obj == 'reg:gamma':
1824
+ tw_power = 2.0
1825
+ else:
1826
+ tw_power = 1.5
1827
+ else: # classification
1828
+ tw_power = None # Not used
1829
+
1830
+ fold_losses = []
1831
+
1832
+ # 👉 可选:只在子样本上做 BO,避免大数据直接压垮显存
1833
+ data_for_cv = self.ctx.train_data
1834
+ max_rows_for_ft_bo = min(1000000, int(
1835
+ len(data_for_cv)/2)) # 你可以根据显存情况调小或调大
1836
+ if len(data_for_cv) > max_rows_for_ft_bo:
1837
+ data_for_cv = data_for_cv.sample(
1838
+ max_rows_for_ft_bo,
1839
+ random_state=self.ctx.rand_seed
1840
+ )
1841
+
1842
+ for _, (train_idx, test_idx) in enumerate(
1843
+ self.ctx.cv.split(data_for_cv[self.ctx.factor_nmes])
1844
+ ):
1845
+ X_train_fold = data_for_cv.iloc[train_idx][self.ctx.factor_nmes]
1846
+ y_train_fold = data_for_cv.iloc[train_idx][self.ctx.resp_nme]
1847
+ w_train_fold = data_for_cv.iloc[train_idx][self.ctx.weight_nme]
1848
+ X_val_fold = data_for_cv.iloc[test_idx][self.ctx.factor_nmes]
1849
+ y_val_fold = data_for_cv.iloc[test_idx][self.ctx.resp_nme]
1850
+ w_val_fold = data_for_cv.iloc[test_idx][self.ctx.weight_nme]
1851
+
1852
+ cv_ft = FTTransformerSklearn(
1853
+ model_nme=self.ctx.model_nme,
1854
+ num_cols=self.ctx.num_features,
1855
+ cat_cols=self.ctx.cate_list,
1856
+ d_model=d_model,
1857
+ n_heads=n_heads,
1858
+ n_layers=n_layers,
1859
+ dropout=dropout,
1860
+ task_type=self.ctx.task_type,
1861
+ # batch_num=batch_num,
1862
+ epochs=self.ctx.epochs,
1863
+ tweedie_power=tw_power,
1864
+ learning_rate=learning_rate,
1865
+ patience=5,
1866
+ use_data_parallel=self.ctx.config.use_ft_data_parallel
1867
+ )
1868
+
1869
+ try:
1870
+ cv_ft.fit(
1871
+ X_train_fold, y_train_fold, w_train_fold,
1872
+ X_val_fold, y_val_fold, w_val_fold
1873
+ )
1874
+ y_pred_fold = cv_ft.predict(X_val_fold)
1875
+ if self.ctx.task_type == 'regression':
1876
+ loss = mean_tweedie_deviance(
1877
+ y_val_fold,
1878
+ y_pred_fold,
1879
+ sample_weight=w_val_fold,
1880
+ power=tw_power
1881
+ )
1882
+ else: # classification
1883
+ from sklearn.metrics import log_loss
1884
+ loss = log_loss(
1885
+ y_val_fold,
1886
+ y_pred_fold,
1887
+ sample_weight=w_val_fold,
1888
+ )
1889
+ fold_losses.append(loss)
1890
+ finally:
1891
+ # 🧹 每个 fold 用完就立即释放 GPU 资源
1892
+ try:
1893
+ # 如果模型在 GPU 上,先挪回 CPU
1894
+ if hasattr(cv_ft, "ft"):
1895
+ cv_ft.ft.to("cpu")
1896
+ except Exception:
1897
+ pass
1898
+ del cv_ft
1899
+ gc.collect()
1900
+ if torch.cuda.is_available():
1901
+ torch.cuda.empty_cache()
1902
+
1903
+ return np.mean(fold_losses)
1904
+
1905
+ def tune(self, max_evals: int = 50) -> None:
1906
+ """
1907
+ 用 Optuna 做超参搜索。
1908
+ 在每个 trial 结束后再做一次显存清理,避免 trial 间显存碎片堆积。
1909
+ """
1910
+ def objective(trial: optuna.trial.Trial) -> float:
1911
+ result = self.cross_val(trial)
1912
+ # trial 级别的兜底清理
1913
+ gc.collect()
1914
+ if torch.cuda.is_available():
1915
+ torch.cuda.empty_cache()
1916
+ return result
1917
+
1918
+ study = optuna.create_study(
1919
+ direction='minimize',
1920
+ sampler=optuna.samplers.TPESampler(seed=self.ctx.rand_seed)
1921
+ )
1922
+ study.optimize(objective, n_trials=max_evals)
1923
+ self.best_params = study.best_params
1924
+ self.best_trial = study.best_trial
1925
+ params_path = self.output.result_path(
1926
+ f'{self.ctx.model_nme}_bestparams_ft.csv'
1927
+ )
1928
+ pd.DataFrame(self.best_params, index=[0]).to_csv(params_path)
1929
+
1930
+ def train(self) -> None:
1931
+ if not self.best_params:
1932
+ raise RuntimeError('请先运行 tune() 以获得 FT-Transformer 最优参数。')
1933
+ self.model = FTTransformerSklearn(
1934
+ model_nme=self.ctx.model_nme,
1935
+ num_cols=self.ctx.num_features,
1936
+ cat_cols=self.ctx.cate_list,
1937
+ task_type=self.ctx.task_type,
1938
+ use_data_parallel=self.ctx.config.use_ft_data_parallel
1939
+ )
1940
+ self.model.set_params(self.best_params)
1941
+ self.model.fit(
1942
+ self.ctx.train_data[self.ctx.factor_nmes],
1943
+ self.ctx.train_data[self.ctx.resp_nme],
1944
+ self.ctx.train_data[self.ctx.weight_nme]
1945
+ )
1946
+ self.ctx.model_label += [self.label]
1947
+ self.ctx.train_data['pred_ft'] = self.model.predict(
1948
+ self.ctx.train_data[self.ctx.factor_nmes]
1949
+ )
1950
+ self.ctx.test_data['pred_ft'] = self.model.predict(
1951
+ self.ctx.test_data[self.ctx.factor_nmes]
1952
+ )
1953
+ self.ctx.train_data.loc[:, 'w_pred_ft'] = (
1954
+ self.ctx.train_data['pred_ft'] *
1955
+ self.ctx.train_data[self.ctx.weight_nme]
1956
+ )
1957
+ self.ctx.test_data.loc[:, 'w_pred_ft'] = (
1958
+ self.ctx.test_data['pred_ft'] *
1959
+ self.ctx.test_data[self.ctx.weight_nme]
1960
+ )
1961
+ self.ctx.ft_best = self.model
1962
+
1963
+ def save(self) -> None:
1964
+ if self.model is not None:
1965
+ torch.save(
1966
+ self.model,
1967
+ self.output.model_path(
1968
+ f'01_{self.ctx.model_nme}_FTTransformer.pth')
1969
+ )
1970
+
1971
+ def load(self) -> None:
1972
+ path = self.output.model_path(
1973
+ f'01_{self.ctx.model_nme}_FTTransformer.pth')
1974
+ if os.path.exists(path):
1975
+ ft_loaded = torch.load(path, map_location='cpu')
1976
+ if torch.cuda.is_available():
1977
+ ft_loaded.device = torch.device('cuda')
1978
+ elif torch.backends.mps.is_available():
1979
+ ft_loaded.device = torch.device('mps')
1980
+ else:
1981
+ ft_loaded.device = torch.device('cpu')
1982
+ ft_loaded.ft.to(ft_loaded.device)
1983
+ self.model = ft_loaded
1984
+ self.ctx.ft_best = self.model
1985
+ else:
1986
+ print(f"[load_model] Warning: 未找到 FT-Transformer 模型文件:{path}")
1987
+
1988
+
1989
+ # =============================================================================
1990
+ # BayesOpt orchestration & SHAP utilities
1991
+ # =============================================================================
1992
+ class BayesOptModel:
1993
+ def __init__(self, train_data, test_data,
1994
+ model_nme, resp_nme, weight_nme, factor_nmes, task_type='regression',
1995
+ binary_resp_nme=None,
1996
+ cate_list=None, prop_test=0.25, rand_seed=None,
1997
+ epochs=100, use_gpu=True,
1998
+ use_resn_data_parallel: bool = False, use_ft_data_parallel: bool = False):
1999
+ cfg = BayesOptConfig(
2000
+ model_nme=model_nme,
2001
+ task_type=task_type,
2002
+ resp_nme=resp_nme,
2003
+ weight_nme=weight_nme,
2004
+ factor_nmes=list(factor_nmes),
2005
+ binary_resp_nme=binary_resp_nme,
2006
+ cate_list=list(cate_list) if cate_list else None,
2007
+ prop_test=prop_test,
2008
+ rand_seed=rand_seed,
2009
+ epochs=epochs,
2010
+ use_gpu=use_gpu,
2011
+ use_resn_data_parallel=use_resn_data_parallel,
2012
+ use_ft_data_parallel=use_ft_data_parallel
2013
+ )
2014
+ self.config = cfg
2015
+ self.model_nme = cfg.model_nme
2016
+ self.task_type = cfg.task_type
2017
+ self.resp_nme = cfg.resp_nme
2018
+ self.weight_nme = cfg.weight_nme
2019
+ self.factor_nmes = cfg.factor_nmes
2020
+ self.binary_resp_nme = cfg.binary_resp_nme
2021
+ self.cate_list = list(cfg.cate_list or [])
2022
+ self.prop_test = cfg.prop_test
2023
+ self.epochs = cfg.epochs
2024
+ self.rand_seed = cfg.rand_seed if cfg.rand_seed is not None else np.random.randint(
2025
+ 1, 10000)
2026
+ self.use_gpu = bool(cfg.use_gpu and torch.cuda.is_available())
2027
+ self.output_manager = OutputManager(os.getcwd(), self.model_nme)
2028
+
2029
+ preprocessor = DatasetPreprocessor(train_data, test_data, cfg).run()
2030
+ self.train_data = preprocessor.train_data
2031
+ self.test_data = preprocessor.test_data
2032
+ self.train_oht_scl_data = preprocessor.train_oht_scl_data
2033
+ self.test_oht_scl_data = preprocessor.test_oht_scl_data
2034
+ self.var_nmes = preprocessor.var_nmes
2035
+ self.num_features = preprocessor.num_features
2036
+ self.cat_categories_for_shap = preprocessor.cat_categories_for_shap
2037
+
2038
+ self.cv = ShuffleSplit(n_splits=int(1/self.prop_test),
2039
+ test_size=self.prop_test,
2040
+ random_state=self.rand_seed)
2041
+ if self.task_type == 'classification':
2042
+ self.obj = 'binary:logistic'
2043
+ else: # regression
2044
+ if 'f' in self.model_nme:
2045
+ self.obj = 'count:poisson'
2046
+ elif 's' in self.model_nme:
2047
+ self.obj = 'reg:gamma'
2048
+ elif 'bc' in self.model_nme:
2049
+ self.obj = 'reg:tweedie'
2050
+ else:
2051
+ self.obj = 'reg:tweedie'
2052
+ self.fit_params = {
2053
+ 'sample_weight': self.train_data[self.weight_nme].values
2054
+ }
2055
+ self.model_label: List[str] = []
2056
+
2057
+ # 记录各模型训练器,后续统一通过标签访问,方便扩展新模型
2058
+ self.trainers: Dict[str, TrainerBase] = {
2059
+ 'xgb': XGBTrainer(self),
2060
+ 'resn': ResNetTrainer(self),
2061
+ 'ft': FTTrainer(self),
2062
+ 'glm': GLMTrainer(self)
2063
+ }
2064
+ self.xgb_best = None
2065
+ self.resn_best = None
2066
+ self.glm_best = None
2067
+ self.ft_best = None
2068
+ self.best_xgb_params = None
2069
+ self.best_resn_params = None
2070
+ self.best_ft_params = None
2071
+ self.best_xgb_trial = None
2072
+ self.best_resn_trial = None
2073
+ self.best_ft_trial = None
2074
+ self.best_glm_params = None
2075
+ self.best_glm_trial = None
2076
+ self.xgb_load = None
2077
+ self.resn_load = None
2078
+ self.ft_load = None
2079
+
2080
+ # 定义单因素画图函数
2081
+ def plot_oneway(self, n_bins=10):
2082
+ for c in self.factor_nmes:
2083
+ fig = plt.figure(figsize=(7, 5))
2084
+ if c in self.cate_list:
2085
+ group_col = c
2086
+ plot_source = self.train_data
2087
+ else:
2088
+ group_col = f'{c}_bins'
2089
+ bins = pd.qcut(
2090
+ self.train_data[c],
2091
+ n_bins,
2092
+ duplicates='drop' # 注意:如果分位数重复会丢 bin,避免异常终止
2093
+ )
2094
+ plot_source = self.train_data.assign(**{group_col: bins})
2095
+ plot_data = plot_source.groupby(
2096
+ [group_col], observed=True).sum(numeric_only=True)
2097
+ plot_data.reset_index(inplace=True)
2098
+ plot_data['act_v'] = plot_data['w_act'] / \
2099
+ plot_data[self.weight_nme]
2100
+ plot_data.head()
2101
+ ax = fig.add_subplot(111)
2102
+ ax.plot(plot_data.index, plot_data['act_v'],
2103
+ label='Actual', color='red')
2104
+ ax.set_title(
2105
+ 'Analysis of %s : Train Data' % group_col,
2106
+ fontsize=8)
2107
+ plt.xticks(plot_data.index,
2108
+ list(plot_data[group_col].astype(str)),
2109
+ rotation=90)
2110
+ if len(list(plot_data[group_col].astype(str))) > 50:
2111
+ plt.xticks(fontsize=3)
2112
+ else:
2113
+ plt.xticks(fontsize=6)
2114
+ plt.yticks(fontsize=6)
2115
+ ax2 = ax.twinx()
2116
+ ax2.bar(plot_data.index,
2117
+ plot_data[self.weight_nme],
2118
+ alpha=0.5, color='seagreen')
2119
+ plt.yticks(fontsize=6)
2120
+ plt.margins(0.05)
2121
+ plt.subplots_adjust(wspace=0.3)
2122
+ save_path = self.output_manager.plot_path(
2123
+ f'00_{self.model_nme}_{group_col}_oneway.png')
2124
+ plt.savefig(save_path, dpi=300)
2125
+ plt.close(fig)
2126
+
2127
+ # 定义Xgboost贝叶斯优化函数
2128
+ def bayesopt_xgb(self, max_evals=100):
2129
+ trainer = self.trainers['xgb']
2130
+ trainer.tune(max_evals)
2131
+ trainer.train()
2132
+ self.xgb_best = trainer.model
2133
+ # 记录最优参数及 trial 以便排查或复现结果
2134
+ self.best_xgb_params = trainer.best_params
2135
+ self.best_xgb_trial = trainer.best_trial
2136
+
2137
+ # 定义GLM贝叶斯优化函数
2138
+ def bayesopt_glm(self, max_evals=50):
2139
+ trainer = self.trainers['glm']
2140
+ trainer.tune(max_evals)
2141
+ trainer.train()
2142
+ self.glm_best = trainer.model
2143
+ self.best_glm_params = trainer.best_params
2144
+ self.best_glm_trial = trainer.best_trial
2145
+
2146
+ # 定义ResNet贝叶斯优化函数
2147
+
2148
+ def bayesopt_resnet(self, max_evals=100):
2149
+ trainer = self.trainers['resn']
2150
+ trainer.tune(max_evals)
2151
+ trainer.train()
2152
+ self.resn_best = trainer.model
2153
+ # 保存最优 trial 相关信息,方便后续调参分析
2154
+ self.best_resn_params = trainer.best_params
2155
+ self.best_resn_trial = trainer.best_trial
2156
+
2157
+ # 定义 FT-Transformer 贝叶斯优化函数
2158
+ def bayesopt_ft(self, max_evals=50):
2159
+ trainer = self.trainers['ft']
2160
+ trainer.tune(max_evals)
2161
+ trainer.train()
2162
+ self.ft_best = trainer.model
2163
+ # FT-Transformer 参数较多,留存配置信息尤其重要
2164
+ self.best_ft_params = trainer.best_params
2165
+ self.best_ft_trial = trainer.best_trial
2166
+
2167
+ # 定义分箱函数
2168
+
2169
+ def _split_data(self, data, col_nme, wgt_nme, n_bins=10):
2170
+ # 先按得分排序再按累计权重等分,能保证每个分箱曝光量接近
2171
+ sorted_data = data.sort_values(by=col_nme, ascending=True).copy()
2172
+ sorted_data['cum_weight'] = sorted_data[wgt_nme].cumsum()
2173
+ w_sum = sorted_data[wgt_nme].sum()
2174
+ if w_sum <= EPS:
2175
+ sorted_data.loc[:, 'bins'] = 0
2176
+ else:
2177
+ sorted_data.loc[:, 'bins'] = np.floor(
2178
+ sorted_data['cum_weight'] * float(n_bins) / w_sum
2179
+ )
2180
+ sorted_data.loc[(sorted_data['bins'] == n_bins),
2181
+ 'bins'] = n_bins - 1
2182
+ return sorted_data.groupby(['bins'], observed=True).sum(numeric_only=True)
2183
+
2184
+ # 构建提纯曲线所需的数据
2185
+ def _plot_data_lift(self,
2186
+ pred_list, w_pred_list,
2187
+ w_act_list, weight_list, n_bins=10):
2188
+ lift_data = pd.DataFrame()
2189
+ lift_data.loc[:, 'pred'] = pred_list
2190
+ lift_data.loc[:, 'w_pred'] = w_pred_list
2191
+ lift_data.loc[:, 'act'] = w_act_list
2192
+ lift_data.loc[:, 'weight'] = weight_list
2193
+ plot_data = self._split_data(
2194
+ lift_data, 'pred', 'weight', n_bins)
2195
+ denom = np.maximum(plot_data['weight'], EPS)
2196
+ plot_data['exp_v'] = plot_data['w_pred'] / denom
2197
+ plot_data['act_v'] = plot_data['act'] / denom
2198
+ plot_data.reset_index(inplace=True)
2199
+ return plot_data
2200
+
2201
+ # 绘制提纯曲线
2202
+ def plot_lift(self, model_label, pred_nme, n_bins=10):
2203
+ # 绘制建模集上结果
2204
+ figpos_list = [121, 122]
2205
+ plot_dict = {
2206
+ 121: self.train_data,
2207
+ 122: self.test_data
2208
+ }
2209
+ name_list = {
2210
+ 121: 'Train Data',
2211
+ 122: 'Test Data'
2212
+ }
2213
+ if model_label == 'Xgboost':
2214
+ pred_nme = 'pred_xgb'
2215
+ elif model_label == 'ResNet':
2216
+ pred_nme = 'pred_resn'
2217
+ elif model_label == 'FTTransformer':
2218
+ pred_nme = 'pred_ft'
2219
+ elif model_label.startswith('ResNetClassifier'):
2220
+ pred_nme = 'pred_resn'
2221
+ elif model_label.startswith('FTTransformerClassifier'):
2222
+ pred_nme = 'pred_ft'
2223
+ elif model_label == 'GLM':
2224
+ pred_nme = 'pred_glm'
2225
+ # pred_nme 映射保证后续取列统一,否则新模型加入时需同步更新
2226
+
2227
+ fig = plt.figure(figsize=(11, 5))
2228
+ for figpos in figpos_list:
2229
+ plot_data = self._plot_data_lift(
2230
+ plot_dict[figpos][pred_nme].values,
2231
+ plot_dict[figpos]['w_'+pred_nme].values,
2232
+ plot_dict[figpos]['w_act'].values,
2233
+ plot_dict[figpos][self.weight_nme].values,
2234
+ n_bins)
2235
+ ax = fig.add_subplot(figpos)
2236
+ ax.plot(plot_data.index, plot_data['act_v'],
2237
+ label='Actual', color='red')
2238
+ ax.plot(plot_data.index, plot_data['exp_v'],
2239
+ label='Predicted', color='blue')
2240
+ ax.set_title(
2241
+ 'Lift Chart on %s' % name_list[figpos], fontsize=8)
2242
+ plt.xticks(plot_data.index,
2243
+ plot_data.index,
2244
+ rotation=90, fontsize=6)
2245
+ plt.yticks(fontsize=6)
2246
+ plt.legend(loc='upper left',
2247
+ fontsize=5, frameon=False)
2248
+ plt.margins(0.05)
2249
+ ax2 = ax.twinx()
2250
+ ax2.bar(plot_data.index, plot_data['weight'],
2251
+ alpha=0.5, color='seagreen',
2252
+ label='Earned Exposure')
2253
+ plt.yticks(fontsize=6)
2254
+ plt.legend(loc='upper right',
2255
+ fontsize=5, frameon=False)
2256
+ plt.subplots_adjust(wspace=0.3)
2257
+ save_path = self.output_manager.plot_path(
2258
+ f'01_{self.model_nme}_{model_label}_lift.png')
2259
+ plt.savefig(save_path, dpi=300)
2260
+ plt.show()
2261
+ plt.close(fig)
2262
+
2263
+ # 构建双提纯曲线所需的数据
2264
+ def _plot_data_dlift(self,
2265
+ pred_list_model1, pred_list_model2,
2266
+ w_list, w_act_list, n_bins=10):
2267
+ lift_data = pd.DataFrame()
2268
+ lift_data.loc[:, 'pred1'] = pred_list_model1
2269
+ lift_data.loc[:, 'pred2'] = pred_list_model2
2270
+ lift_data.loc[:, 'diff_ly'] = lift_data['pred1'] / lift_data['pred2']
2271
+ lift_data.loc[:, 'act'] = w_act_list
2272
+ lift_data.loc[:, 'weight'] = w_list
2273
+ plot_data = self._split_data(lift_data, 'diff_ly', 'weight', n_bins)
2274
+ denom = np.maximum(plot_data['act'], EPS)
2275
+ plot_data['exp_v1'] = plot_data['pred1'] / denom
2276
+ plot_data['exp_v2'] = plot_data['pred2'] / denom
2277
+ plot_data['act_v'] = plot_data['act'] / denom
2278
+ plot_data.reset_index(inplace=True)
2279
+ return plot_data
2280
+
2281
+ # 绘制双提纯曲线
2282
+ def plot_dlift(self, model_comp: List[str] = ['xgb', 'resn'], n_bins: int = 10) -> None:
2283
+ """
2284
+ 绘制双提纯曲线,对比两个模型的预测效果。
2285
+
2286
+ Args:
2287
+ model_comp: 包含两个模型简称的列表,例如 ['xgb', 'resn']。
2288
+ 支持 'xgb', 'resn', 'ft'。
2289
+ n_bins: 分箱数量。
2290
+ """
2291
+ if len(model_comp) != 2:
2292
+ raise ValueError("`model_comp` 必须包含两个模型进行对比。")
2293
+
2294
+ model_name_map = {
2295
+ 'xgb': 'Xgboost',
2296
+ 'resn': 'ResNet',
2297
+ 'ft': 'FTTransformer',
2298
+ 'glm': 'GLM'
2299
+ }
2300
+
2301
+ name1, name2 = model_comp
2302
+ if name1 not in model_name_map or name2 not in model_name_map:
2303
+ raise ValueError(f"不支持的模型简称。请从 {list(model_name_map.keys())} 中选择。")
2304
+
2305
+ fig, axes = plt.subplots(1, 2, figsize=(11, 5))
2306
+ datasets = {
2307
+ 'Train Data': self.train_data,
2308
+ 'Test Data': self.test_data
2309
+ }
2310
+
2311
+ for ax, (data_name, data) in zip(axes, datasets.items()):
2312
+ pred1_col = f'w_pred_{name1}'
2313
+ pred2_col = f'w_pred_{name2}'
2314
+
2315
+ if pred1_col not in data.columns or pred2_col not in data.columns:
2316
+ print(
2317
+ f"警告: 在 {data_name} 中找不到预测列 {pred1_col} 或 {pred2_col}。跳过绘图。")
2318
+ continue
2319
+
2320
+ plot_data = self._plot_data_dlift(
2321
+ data[pred1_col].values,
2322
+ data[pred2_col].values,
2323
+ data[self.weight_nme].values,
2324
+ data['w_act'].values,
2325
+ n_bins
2326
+ )
2327
+
2328
+ label1 = model_name_map[name1]
2329
+ label2 = model_name_map[name2]
2330
+
2331
+ ax.plot(plot_data.index,
2332
+ plot_data['act_v'], label='Actual', color='red')
2333
+ ax.plot(plot_data.index,
2334
+ plot_data['exp_v1'], label=label1, color='blue')
2335
+ ax.plot(plot_data.index,
2336
+ plot_data['exp_v2'], label=label2, color='black')
2337
+
2338
+ ax.set_title(f'Double Lift Chart on {data_name}', fontsize=8)
2339
+ ax.set_xticks(plot_data.index)
2340
+ ax.set_xticklabels(plot_data.index, rotation=90, fontsize=6)
2341
+ ax.set_xlabel(f'{label1} / {label2}', fontsize=6)
2342
+ ax.tick_params(axis='y', labelsize=6)
2343
+ ax.legend(loc='upper left', fontsize=5, frameon=False)
2344
+ ax.margins(0.1)
2345
+
2346
+ ax2 = ax.twinx()
2347
+ ax2.bar(plot_data.index, plot_data['weight'],
2348
+ alpha=0.5, color='seagreen', label='Earned Exposure')
2349
+ ax2.tick_params(axis='y', labelsize=6)
2350
+ ax2.legend(loc='upper right', fontsize=5, frameon=False)
2351
+
2352
+ plt.subplots_adjust(bottom=0.25, top=0.95, right=0.8, wspace=0.3)
2353
+ save_path = self.output_manager.plot_path(
2354
+ f'02_{self.model_nme}_dlift_{name1}_vs_{name2}.png')
2355
+ plt.savefig(save_path, dpi=300)
2356
+ plt.show()
2357
+ plt.close(fig)
2358
+
2359
+ # 绘制成交率提升曲线
2360
+ def plot_conversion_lift(self, model_pred_col: str, n_bins: int = 20):
2361
+ if not self.binary_resp_nme:
2362
+ print("错误: 未在 BayesOptModel 初始化时提供 `binary_resp_nme`。无法绘制成交率曲线。")
2363
+ return
2364
+
2365
+ fig, axes = plt.subplots(1, 2, figsize=(14, 6), sharey=True)
2366
+ datasets = {
2367
+ 'Train Data': self.train_data,
2368
+ 'Test Data': self.test_data
2369
+ }
2370
+
2371
+ for ax, (data_name, data) in zip(axes, datasets.items()):
2372
+ if model_pred_col not in data.columns:
2373
+ print(f"警告: 在 {data_name} 中找不到预测列 '{model_pred_col}'。跳过绘图。")
2374
+ continue
2375
+
2376
+ # 按模型预测分排序,并计算分箱
2377
+ plot_data = data.sort_values(by=model_pred_col).copy()
2378
+ plot_data['cum_weight'] = plot_data[self.weight_nme].cumsum()
2379
+ total_weight = plot_data[self.weight_nme].sum()
2380
+
2381
+ if total_weight > EPS:
2382
+ plot_data['bin'] = pd.cut(
2383
+ plot_data['cum_weight'],
2384
+ bins=n_bins,
2385
+ labels=False,
2386
+ right=False
2387
+ )
2388
+ else:
2389
+ plot_data['bin'] = 0
2390
+
2391
+ # 按分箱聚合
2392
+ lift_agg = plot_data.groupby('bin').agg(
2393
+ total_weight=(self.weight_nme, 'sum'),
2394
+ actual_conversions=(self.binary_resp_nme, 'sum'),
2395
+ weighted_conversions=('w_binary_act', 'sum'),
2396
+ avg_pred=(model_pred_col, 'mean')
2397
+ ).reset_index()
2398
+
2399
+ # 计算成交率
2400
+ lift_agg['conversion_rate'] = lift_agg['weighted_conversions'] / \
2401
+ lift_agg['total_weight']
2402
+
2403
+ # 计算整体平均成交率
2404
+ overall_conversion_rate = data['w_binary_act'].sum(
2405
+ ) / data[self.weight_nme].sum()
2406
+ ax.axhline(y=overall_conversion_rate, color='gray', linestyle='--',
2407
+ label=f'Overall Avg Rate ({overall_conversion_rate:.2%})')
2408
+
2409
+ ax.plot(lift_agg['bin'], lift_agg['conversion_rate'],
2410
+ marker='o', linestyle='-', label='Actual Conversion Rate')
2411
+ ax.set_title(f'Conversion Rate Lift Chart on {data_name}')
2412
+ ax.set_xlabel(f'Model Score Decile (based on {model_pred_col})')
2413
+ ax.set_ylabel('Conversion Rate')
2414
+ ax.grid(True, linestyle='--', alpha=0.6)
2415
+ ax.legend()
2416
+
2417
+ plt.tight_layout()
2418
+ plt.show()
2419
+
2420
+ # 保存模型
2421
+
2422
+ def save_model(self, model_name=None):
2423
+
2424
+ # model_name 可以是:
2425
+ # - None: 保存全部可用模型
2426
+ # - 'xgb': 只保存 Xgboost
2427
+ # - 'resn': 只保存 ResNet
2428
+ # - 'ft': 只保存 FT-Transformer
2429
+ # - 'glm': 只保存 GLM
2430
+ if model_name in (None, 'xgb'):
2431
+ trainer = self.trainers['xgb']
2432
+ if trainer.model is not None:
2433
+ trainer.save()
2434
+ else:
2435
+ print("[save_model] Warning: xgb_best 不存在,未保存 Xgboost 模型。")
2436
+
2437
+ if model_name in (None, 'resn'):
2438
+ trainer = self.trainers['resn']
2439
+ if trainer.model is not None:
2440
+ trainer.save()
2441
+ else:
2442
+ print("[save_model] Warning: resn_best 不存在,未保存 ResNet 模型。")
2443
+
2444
+ if model_name in (None, 'ft'):
2445
+ trainer = self.trainers['ft']
2446
+ if trainer.model is not None:
2447
+ trainer.save()
2448
+ else:
2449
+ print("[save_model] Warning: ft_best 不存在,未保存 FT-Transformer 模型。")
2450
+
2451
+ if model_name in (None, 'glm'):
2452
+ trainer = self.trainers['glm']
2453
+ if trainer.model is not None:
2454
+ trainer.save()
2455
+ else:
2456
+ print("[save_model] Warning: glm_best 不存在,未保存 GLM 模型。")
2457
+
2458
+ def load_model(self, model_name=None):
2459
+ # model_name 可以是:
2460
+ # - None: 加载全部能找到的模型
2461
+ # - 'xgb': 只加载 Xgboost
2462
+ # - 'resn': 只加载 ResNet
2463
+ # - 'ft': 只加载 FT-Transformer
2464
+ # - 'glm': 只加载 GLM
2465
+
2466
+ if model_name in (None, 'xgb'):
2467
+ trainer = self.trainers['xgb']
2468
+ trainer.load()
2469
+ self.xgb_best = trainer.model
2470
+ self.xgb_load = trainer.model
2471
+
2472
+ if model_name in (None, 'resn'):
2473
+ trainer = self.trainers['resn']
2474
+ trainer.load()
2475
+ self.resn_best = trainer.model
2476
+ self.resn_load = trainer.model
2477
+
2478
+ if model_name in (None, 'ft'):
2479
+ trainer = self.trainers['ft']
2480
+ trainer.load()
2481
+ self.ft_best = trainer.model
2482
+ self.ft_load = trainer.model
2483
+
2484
+ if model_name in (None, 'glm'):
2485
+ trainer = self.trainers['glm']
2486
+ trainer.load()
2487
+ self.glm_best = trainer.model
2488
+
2489
+ def _build_ft_shap_matrix(self, data: pd.DataFrame) -> np.ndarray:
2490
+
2491
+ # 将原始特征 DataFrame (包含 self.factor_nmes) 转成
2492
+ # 纯数值矩阵: 数值列为 float64,类别列为整数 code(float64 存储)。
2493
+ # 列顺序与 self.factor_nmes 保持一致。
2494
+
2495
+ matrices = []
2496
+
2497
+ for col in self.factor_nmes:
2498
+ s = data[col]
2499
+
2500
+ if col in self.cate_list:
2501
+ # 类别列:按训练时的类别全集编码
2502
+ cats = pd.Categorical(
2503
+ s,
2504
+ categories=self.cat_categories_for_shap[col]
2505
+ )
2506
+ # cats.codes 是一个 Index / ndarray,用 np.asarray 包一下再 reshape
2507
+ codes = np.asarray(cats.codes, dtype=np.float64).reshape(-1, 1)
2508
+ matrices.append(codes)
2509
+ else:
2510
+ # 数值列:转成 Series -> numpy -> reshape
2511
+ vals = pd.to_numeric(s, errors="coerce")
2512
+ arr = vals.to_numpy(dtype=np.float64, copy=True).reshape(-1, 1)
2513
+ matrices.append(arr)
2514
+
2515
+ X_mat = np.concatenate(matrices, axis=1) # (N, F)
2516
+ return X_mat
2517
+
2518
+ def _decode_ft_shap_matrix_to_df(self, X_mat: np.ndarray) -> pd.DataFrame:
2519
+
2520
+ # 将 SHAP 的数值矩阵 (N, F) 还原为原始特征 DataFrame,
2521
+ # 数值列为 float,类别列还原为 pandas 的 category 类型,
2522
+ # 以便兼容 enable_categorical=True 的 XGBoost 和 FT-Transformer 的输入。
2523
+ # 列顺序 = self.factor_nmes
2524
+
2525
+ data_dict = {}
2526
+
2527
+ for j, col in enumerate(self.factor_nmes):
2528
+ col_vals = X_mat[:, j]
2529
+
2530
+ if col in self.cate_list:
2531
+ cats = self.cat_categories_for_shap[col]
2532
+
2533
+ # SHAP 会扰动成小数,这里 round 回整数 code
2534
+ codes = np.round(col_vals).astype(int)
2535
+ # 限制在 [-1, len(cats)-1]
2536
+ codes = np.clip(codes, -1, len(cats) - 1)
2537
+
2538
+ # 使用 pandas.Categorical.from_codes:
2539
+ # - codes = -1 被当成缺失 (NaN)
2540
+ # - 其他索引映射到 cats 中对应的类别
2541
+ cat_series = pd.Categorical.from_codes(
2542
+ codes,
2543
+ categories=cats
2544
+ )
2545
+ # 存的是 Categorical 类型,而不是 object
2546
+ data_dict[col] = cat_series
2547
+ else:
2548
+ # 数值列:直接 float
2549
+ data_dict[col] = col_vals.astype(float)
2550
+
2551
+ df = pd.DataFrame(data_dict, columns=self.factor_nmes)
2552
+
2553
+ # 再保险:确保所有类别列 dtype 真的是 category
2554
+ for col in self.cate_list:
2555
+ if col in df.columns:
2556
+ df[col] = df[col].astype("category")
2557
+ return df
2558
+
2559
+ def _build_glm_design(self, data: pd.DataFrame) -> pd.DataFrame:
2560
+ # 与 GLM 训练阶段一致:在 one-hot + 标准化特征上添加截距
2561
+ X = data[self.var_nmes]
2562
+ return sm.add_constant(X, has_constant='add')
2563
+
2564
+ # ========= XGBoost SHAP =========
2565
+
2566
+ def compute_shap_xgb(self, n_background: int = 500,
2567
+ n_samples: int = 200,
2568
+ on_train: bool = True):
2569
+ # 使用 KernelExplainer 计算 XGBoost 的 SHAP 值(黑盒方式)。
2570
+ #
2571
+ # - 对 SHAP:输入是一份纯数值矩阵:
2572
+ # * 数值特征:float64
2573
+ # * 类别特征:用 _build_ft_shap_matrix 编码后的整数 code(float64)
2574
+ # - 对模型:仍然用原始 DataFrame + xgb_best.predict(...)
2575
+
2576
+ if not hasattr(self, "xgb_best"):
2577
+ raise RuntimeError("请先运行 bayesopt_xgb() 训练好 self.xgb_best")
2578
+
2579
+ # 1) 选择数据源:训练集 or 测试集(原始特征空间)
2580
+ data = self.train_data if on_train else self.test_data
2581
+ X_raw = data[self.factor_nmes]
2582
+
2583
+ # 2) 构造背景矩阵(用和 FT 一样的数值编码)
2584
+ background_raw = X_raw.sample(
2585
+ min(len(X_raw), n_background),
2586
+ random_state=self.rand_seed
2587
+ )
2588
+ # KernelExplainer 计算量极大,务必控制背景样本规模,否则会拖慢调试
2589
+ background_mat = self._build_ft_shap_matrix(
2590
+ background_raw
2591
+ ).astype(np.float64, copy=True)
2592
+
2593
+ # 3) 定义黑盒预测函数:数值矩阵 -> DataFrame -> xgb_best.predict
2594
+ def f_predict(x_mat: np.ndarray) -> np.ndarray:
2595
+ # 把编码矩阵还原成原始 DataFrame(数值+类别)
2596
+ df_input = self._decode_ft_shap_matrix_to_df(x_mat)
2597
+ # 注意:这里用的是 self.xgb_best.predict,和你训练/预测时一致
2598
+ y_pred = self.xgb_best.predict(df_input)
2599
+ return y_pred
2600
+
2601
+ explainer = shap.KernelExplainer(f_predict, background_mat)
2602
+
2603
+ # 4) 要解释的样本:原始特征 + 数值编码
2604
+ X_explain_raw = X_raw.sample(
2605
+ min(len(X_raw), n_samples),
2606
+ random_state=self.rand_seed
2607
+ )
2608
+ X_explain_mat = self._build_ft_shap_matrix(
2609
+ X_explain_raw
2610
+ ).astype(np.float64, copy=True)
2611
+
2612
+ # 5) 计算 SHAP 值(注意用 nsamples='auto' 控制复杂度)
2613
+ shap_values = explainer.shap_values(X_explain_mat, nsamples="auto")
2614
+
2615
+ # 6) 保存结果:
2616
+ # - shap_values:数值编码空间,对应 factor_nmes 的每一列
2617
+ # - X_explain_raw:原始 DataFrame,方便画图时显示真实类别名
2618
+ self.shap_xgb = {
2619
+ "explainer": explainer,
2620
+ "X_explain": X_explain_raw,
2621
+ "shap_values": shap_values,
2622
+ "base_value": explainer.expected_value,
2623
+ }
2624
+ return self.shap_xgb
2625
+ # ========= ResNet SHAP =========
2626
+
2627
+ def _resn_predict_wrapper(self, X_np):
2628
+ # 保证走 CPU
2629
+ model = self.resn_best.resnet.to("cpu")
2630
+ with torch.no_grad():
2631
+ # 不要 .to(self.device)
2632
+ X_tensor = torch.tensor(X_np, dtype=torch.float32)
2633
+ y_pred = model(X_tensor).cpu().numpy()
2634
+ y_pred = np.clip(y_pred, 1e-6, None)
2635
+ return y_pred.reshape(-1)
2636
+
2637
+ def compute_shap_resn(self, n_background: int = 500,
2638
+ n_samples: int = 200,
2639
+ on_train: bool = True):
2640
+
2641
+ # 使用 KernelExplainer 计算 ResNet 的 SHAP 值。
2642
+ # 解释空间:已 one-hot & 标准化后的特征 self.var_nmes。
2643
+
2644
+ if not hasattr(self, 'resn_best'):
2645
+ raise RuntimeError("请先运行 bayesopt_resnet() 训练好 resn_best")
2646
+
2647
+ self.resn_best.device = torch.device("cpu") # 强制走 CPU
2648
+ self.resn_best.resnet.to("cpu")
2649
+ if torch.cuda.is_available():
2650
+ torch.cuda.empty_cache()
2651
+
2652
+ # 选择数据集(已 one-hot & 标准化)
2653
+ data = self.train_oht_scl_data if on_train else self.test_oht_scl_data
2654
+ X = data[self.var_nmes]
2655
+ if len(X) == 0:
2656
+ raise ValueError(
2657
+ "compute_shap_resn: 选择的数据集为空(len(X)==0),无法计算 SHAP。")
2658
+
2659
+ # 背景样本:float64 numpy
2660
+ background_df = X.sample(
2661
+ min(len(X), n_background),
2662
+ random_state=self.rand_seed
2663
+ )
2664
+ background_np = background_df.to_numpy(dtype=np.float64, copy=True)
2665
+
2666
+ # 黑盒预测函数
2667
+ def f_predict(x):
2668
+ y = self._resn_predict_wrapper(x)
2669
+ # 保证是一维数组
2670
+ y = np.asarray(y, dtype=np.float64).reshape(-1)
2671
+ return y
2672
+
2673
+ explainer = shap.KernelExplainer(f_predict, background_np)
2674
+
2675
+ # 要解释的样本
2676
+ X_explain_df = X.sample(
2677
+ min(len(X), n_samples),
2678
+ random_state=self.rand_seed
2679
+ )
2680
+ X_explain_np = X_explain_df.to_numpy(dtype=np.float64, copy=True)
2681
+
2682
+ max_nsamples = 300
2683
+ min_needed = X_explain_np.shape[1] + 2
2684
+ nsample_eff = max(min_needed, min(max_nsamples,
2685
+ X_explain_np.shape[0] * X_explain_np.shape[1]))
2686
+ shap_values = explainer.shap_values(X_explain_np, nsamples=nsample_eff)
2687
+ # 手动计算 base_value,避免 NotOneValueFound
2688
+ bg_pred = f_predict(background_np)
2689
+ if bg_pred.size == 0:
2690
+ raise ValueError("compute_shap_resn: 背景样本预测结果为空,无法计算 base_value。")
2691
+ base_value = float(bg_pred.mean())
2692
+
2693
+ self.shap_resn = {
2694
+ "explainer": explainer,
2695
+ "X_explain": X_explain_df, # DataFrame: 用于画图(有列名)
2696
+ "shap_values": shap_values, # numpy: (n_samples, n_features)
2697
+ # "base_value": explainer.expected_value,
2698
+ "base_value": base_value,
2699
+ }
2700
+ return self.shap_resn
2701
+
2702
+ # ========= FT-Transformer SHAP =========
2703
+
2704
+ def _ft_shap_predict_wrapper(self, X_mat: np.ndarray) -> np.ndarray:
2705
+
2706
+ # SHAP 的预测包装:
2707
+ # 数值矩阵 -> 还原为原始特征 DataFrame -> 调用 ft_best.predict
2708
+
2709
+ df_input = self._decode_ft_shap_matrix_to_df(X_mat)
2710
+ y_pred = self.ft_best.predict(df_input)
2711
+ return np.asarray(y_pred, dtype=np.float64).reshape(-1)
2712
+
2713
+ def compute_shap_ft(self, n_background: int = 500,
2714
+ n_samples: int = 200,
2715
+ on_train: bool = True):
2716
+
2717
+ # 使用 KernelExplainer 计算 FT-Transformer 的 SHAP 值。
2718
+ # 解释空间:数值+类别code 的混合数值矩阵(float64),
2719
+ # 但对外展示时仍使用原始特征名/取值(X_explain)。
2720
+
2721
+ if not hasattr(self, "ft_best"):
2722
+ raise RuntimeError("请先运行 bayesopt_ft() 训练好 ft_best")
2723
+
2724
+ self.ft_best.device = torch.device("cpu") # 强制走 CPU
2725
+ self.ft_best.ft.to("cpu")
2726
+ if torch.cuda.is_available():
2727
+ torch.cuda.empty_cache()
2728
+
2729
+ # 选择数据源(原始特征空间)
2730
+ data = self.train_data if on_train else self.test_data
2731
+ X_raw = data[self.factor_nmes]
2732
+
2733
+ # 背景矩阵
2734
+ background_raw = X_raw.sample(
2735
+ min(len(X_raw), n_background),
2736
+ random_state=self.rand_seed
2737
+ )
2738
+ background_mat = self._build_ft_shap_matrix(
2739
+ background_raw
2740
+ ).astype(np.float64, copy=True)
2741
+
2742
+ # 黑盒预测函数(数值矩阵 → DataFrame → FT 模型)
2743
+ def f_predict(x):
2744
+ return self._ft_shap_predict_wrapper(x)
2745
+
2746
+ explainer = shap.KernelExplainer(f_predict, background_mat)
2747
+
2748
+ # 要解释的样本(原始特征空间)
2749
+ X_explain_raw = X_raw.sample(
2750
+ min(len(X_raw), n_samples),
2751
+ random_state=self.rand_seed
2752
+ )
2753
+ X_explain_mat = self._build_ft_shap_matrix(
2754
+ X_explain_raw
2755
+ ).astype(np.float64, copy=True)
2756
+
2757
+ max_nsamples = 300
2758
+ min_needed = X_explain_mat.shape[1] + 2
2759
+ nsample_eff = max(min_needed, min(max_nsamples,
2760
+ X_explain_mat.shape[0] * X_explain_mat.shape[1]))
2761
+ shap_values = explainer.shap_values(
2762
+ X_explain_mat, nsamples=nsample_eff)
2763
+ bg_pred = self._ft_shap_predict_wrapper(background_mat)
2764
+ bg_pred = np.asarray(bg_pred, dtype=np.float64).reshape(-1)
2765
+ base_value = float(bg_pred.mean())
2766
+
2767
+ self.shap_ft = {
2768
+ "explainer": explainer,
2769
+ "X_explain": X_explain_raw, # 原始特征 DataFrame,用来画图
2770
+ "shap_values": shap_values, # numpy: (n_samples, n_features)
2771
+ # "base_value": explainer.expected_value,
2772
+ "base_value": base_value,
2773
+ }
2774
+ return self.shap_ft
2775
+
2776
+ # ========= GLM SHAP =========
2777
+ def compute_shap_glm(self, n_background: int = 500,
2778
+ n_samples: int = 200,
2779
+ on_train: bool = True):
2780
+ """
2781
+ 使用 KernelExplainer 计算 GLM 的 SHAP 值。
2782
+ 解释空间:one-hot + 标准化 + 截距项(与 GLM 训练一致)。
2783
+ """
2784
+ if not hasattr(self, "glm_best") or self.glm_best is None:
2785
+ raise RuntimeError("请先运行 bayesopt_glm() 训练好 glm_best")
2786
+
2787
+ data = self.train_oht_scl_data if on_train else self.test_oht_scl_data
2788
+ if len(data) == 0:
2789
+ raise ValueError("compute_shap_glm: 选择的数据集为空,无法计算 SHAP。")
2790
+
2791
+ # 构造设计矩阵
2792
+ design_all = self._build_glm_design(data)
2793
+
2794
+ # 背景样本
2795
+ background_df = design_all.sample(
2796
+ min(len(design_all), n_background),
2797
+ random_state=self.rand_seed
2798
+ )
2799
+ background_np = background_df.to_numpy(dtype=np.float64, copy=True)
2800
+ design_cols = list(design_all.columns)
2801
+
2802
+ def f_predict(x_np: np.ndarray) -> np.ndarray:
2803
+ # 转成 DataFrame 以保持列顺序与截距
2804
+ x_df = pd.DataFrame(x_np, columns=design_cols)
2805
+ y_pred = self.glm_best.predict(x_df)
2806
+ return np.asarray(y_pred, dtype=np.float64).reshape(-1)
2807
+
2808
+ explainer = shap.KernelExplainer(f_predict, background_np)
2809
+
2810
+ # 要解释的样本
2811
+ explain_df = design_all.sample(
2812
+ min(len(design_all), n_samples),
2813
+ random_state=self.rand_seed
2814
+ )
2815
+ explain_np = explain_df.to_numpy(dtype=np.float64, copy=True)
2816
+
2817
+ max_nsamples = 300
2818
+ min_needed = explain_np.shape[1] + 2
2819
+ nsample_eff = max(min_needed, min(max_nsamples,
2820
+ explain_np.shape[0] * explain_np.shape[1]))
2821
+ shap_values = explainer.shap_values(explain_np, nsamples=nsample_eff)
2822
+ bg_pred = f_predict(background_np)
2823
+ base_value = float(np.asarray(bg_pred, dtype=np.float64).mean())
2824
+
2825
+ self.shap_glm = {
2826
+ "explainer": explainer,
2827
+ "X_explain": explain_df, # 包含 const + 特征,用于画图
2828
+ "shap_values": shap_values,
2829
+ "base_value": base_value,
2830
+ "design_columns": design_cols
2831
+ }
2832
+ return self.shap_glm