恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
sklearn封装Keras模型实现稳定超参数搜索
首页
资讯中心
/
sklearn封装Keras模型实现稳定超参数搜索
sklearn封装Keras模型实现稳定超参数搜索
发布时间:2026/10/14 21:59:41
1. 项目概述为什么用sklearn封装Keras模型做超参数搜索而不是直接上Keras Tuner“TensorFlow2.0入门到进阶2.17 —— 超参数搜索sklearn封装keras模型及超参数搜索”这个标题乍看有点绕但拆开来看它其实讲的是一个非常务实、在真实项目中高频出现的技术组合用scikit-learn那套成熟、稳定、接口统一的超参数搜索框架比如GridSearchCV、RandomizedSearchCV去调优你自己写的Keras模型。不是用Keras Tuner也不是用Optuna或Ray Tune而是回归到最经典、最易调试、最易嵌入现有工程流程的方式——sklearn风格的fit-predict-score闭环。我带过不少刚从课程里学完Keras的同学做实际项目他们第一反应往往是“我要用Keras Tuner”结果卡在回调配置、日志路径、多GPU兼容性、甚至Tuner对象序列化失败上。而另一边某高校实验室在部署一个轻量级工业缺陷分类系统时要求模型必须能在32GB内存的边缘设备上完成完整训练调参流程最后他们选的方案就是sklearn Keras Model Wrapper——整个搜索过程可复现、可打断、可记录、可集成进CI/CD流水线连测试工程师都能看懂参数配置表。核心关键词就三个sklearn封装、Keras模型、超参数搜索。它解决的不是“能不能搜”的问题而是“怎么搜得稳、搜得清、搜得进生产环境”的问题。适合三类人一是正在写毕业设计需要快速出结果的学生二是接手老项目要补全调参流程的工程师三是数据科学团队里负责模型交付、需要把算法模块标准化封装的MLOps支持人员。它不追求SOTA指标但能让你在两周内交出一份参数有依据、过程可审计、结果可复现的模型报告。这里有个关键认知差很多人以为sklearn只能搞传统机器学习其实只要你的模型满足fit(X, y)和predict(X)这两个接口它就能被GridSearchCV“当人看”。Keras模型本身不满足但加一层薄薄的Wrapper它就立刻变成sklearn世界里的“合法公民”。这层封装不是炫技而是为了接入整套scikit-learn生态——交叉验证策略、评分函数、参数空间定义、结果汇总报表全都不用重造轮子。你写的模型还是那个模型只是换了一套“工装”就能进标准化工厂流水线了。2. 整体设计思路与方案选型逻辑2.1 为什么不用Keras Tuner——从工程落地角度的硬对比Keras Tuner确实强大支持Bayesian优化、Hyperband剪枝、多工作进程并行但它的设计哲学是“研究导向”鼓励你探索更大参数空间、更长训练时间、更多试验轮次。而实际项目里我们常面临这些约束内存墙Keras Tuner默认会缓存每个trial的完整model对象100个trial × 每个model 500MB 50GB内存占用。某公司部署文本分类模型时光是加载tuner对象就触发OOM最后降级为单trial顺序执行反而比sklearn慢3倍。中断恢复难Keras Tuner的checkpoint机制依赖h5或SavedModel格式保存但一旦中间某个trial因OOM崩溃恢复时容易读取损坏的权重文件报错信息晦涩如ValueError: Unable to load weights...排查耗时远超重跑。评分逻辑耦合深Keras Tuner的Objective必须是val_loss或val_accuracy这类内置指标若你要用自定义F1-macro、加权AUC、或业务侧的“误杀率0.5%”硬约束就得重写Oracle类代码量翻倍且调试成本高。反观sklearn方案每个参数组合独立构建模型、独立训练、独立评估失败即丢弃不影响其他组合所有中间结果训练日志、验证分数、最佳epoch都由你控制写入CSV或数据库评分函数完全自由——你可以传入make_scorer(f1_score, averagemacro)也可以写个lambda函数计算“召回率0.9时的精确率”。提示这不是贬低Keras Tuner而是明确适用边界。如果你在做顶会论文、有GPU集群、目标是刷榜Keras Tuner是首选但如果你在交付一个客户验收的OCR识别模块要求“下周三前给出最优参数组合及置信区间”sklearn封装就是更稳妥的选择。2.2 Wrapper设计的三种实现路径与我的实测选择封装Keras模型到sklearn本质是实现一个符合BaseEstimator和ClassifierMixin或RegressorMixin协议的类。网上常见三种写法我全部实测过结论很明确方案A继承keras.wrappers.scikit_learn.KerasClassifier这是官方提供的快捷方式代码最短from keras.wrappers.scikit_learn import KerasClassifier model KerasClassifier(build_fncreate_model, epochs10, batch_size32)但它有致命缺陷build_fn必须是无参函数所有超参数如units,dropout_rate只能通过闭包或全局变量传入导致param_distributions无法动态绑定GridSearchCV根本没法扫参。实测下来它只适合固定结构模型的简单调参比如只调batch_size和epochs。方案B手写Wrapper类但把fit()方法做成“训练一次就返回”很多人图省事在fit()里直接调model.fit(..., epochs10)然后return self。这会导致严重偏差sklearn的交叉验证会多次调用fit()每次都是从头训练10个epoch而实际最优epoch数可能在第7个就收敛了。结果就是你搜出来的“最优batch_size64”其实是基于欠训练模型的假优势。方案C我的最终选择——带EarlyStopping感知的Wrapper核心思想是让Wrapper自己管理训练循环内置EarlyStopping并把best_epoch、val_score等关键指标作为属性暴露出来。这样GridSearchCV拿到的不是“训练完10个epoch的模型”而是“在验证集上表现最好的那个快照”。代码稍长但稳定性提升一个数量级。后面实操部分会完整展开。2.3 参数空间设计哪些该搜哪些坚决不能搜超参数搜索不是“越多越好”而是“精准打击”。我整理了一份工业级参数清单按优先级排序参数类型推荐搜索范围理由说明网络结构类dense_units[64,128,256],num_layers[1,2,3]直接影响模型容量对小数据集尤其敏感。但num_layers3在多数CV/NLP任务中收益递减且增加搜索空间维度。正则化类dropout_rate[0.2,0.3,0.5],l2_lambda[1e-5,1e-4,1e-3]防止过拟合的核心杠杆。dropout_rate超过0.5常导致训练不稳定l2_lambda超过1e-2会使权重衰减过猛。优化器类learning_rate[1e-3,5e-4,1e-4],optimizer[adam,rmsprop]学习率是最大敏感参数必须搜优化器类型影响收敛路径但adam在90%场景下已足够好。训练策略类batch_size[16,32,64],patience[5,10]batch_size影响梯度更新质量patience决定EarlyStopping宽容度二者需协同调整。注意绝对不要搜索epochs它不是超参数而是训练过程的终止条件。正确做法是固定一个足够大的epochs如200靠EarlyStopping自动停。否则GridSearchCV会把“运气好早停”的组合误判为最优。3. 核心细节解析与实操要点3.1 Wrapper类的完整实现不只是接口更是训练逻辑的掌控权下面是我在线上项目中稳定运行两年的KerasClassifierWrapper代码它解决了前述所有痛点import numpy as np from sklearn.base import BaseEstimator, ClassifierMixin from sklearn.utils.validation import check_X_y, check_array from sklearn.utils.multiclass import unique_labels from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, Dropout, BatchNormalization from tensorflow.keras.optimizers import Adam, RMSprop from tensorflow.keras.callbacks import EarlyStopping from tensorflow.keras import backend as K class KerasClassifierWrapper(BaseEstimator, ClassifierMixin): def __init__(self, input_dimNone, n_classesNone, dense_units128, num_layers2, dropout_rate0.3, l2_lambda1e-5, learning_rate1e-3, optimizeradam, batch_size32, patience10, max_epochs200): # 保存所有超参数为实例属性供get_params/set_params使用 self.input_dim input_dim self.n_classes n_classes self.dense_units dense_units self.num_layers num_layers self.dropout_rate dropout_rate self.l2_lambda l2_lambda self.learning_rate learning_rate self.optimizer optimizer self.batch_size batch_size self.patience patience self.max_epochs max_epochs # 运行时状态用于存储训练结果 self.model_ None self.classes_ None self.best_epoch_ None self.val_score_ None def _build_model(self): 根据当前超参数构建Keras模型 model Sequential() # 输入层 model.add(Dense(self.dense_units, activationrelu, input_shape(self.input_dim,), kernel_regularizertf.keras.regularizers.l2(self.l2_lambda))) model.add(Dropout(self.dropout_rate)) # 隐藏层 for _ in range(self.num_layers - 1): model.add(Dense(self.dense_units, activationrelu, kernel_regularizertf.keras.regularizers.l2(self.l2_lambda))) model.add(Dropout(self.dropout_rate)) # 输出层 if self.n_classes 2: model.add(Dense(1, activationsigmoid)) loss binary_crossentropy metrics [accuracy] else: model.add(Dense(self.n_classes, activationsoftmax)) loss sparse_categorical_crossentropy metrics [accuracy] # 优化器 if self.optimizer adam: opt Adam(learning_rateself.learning_rate) else: opt RMSprop(learning_rateself.learning_rate) model.compile(optimizeropt, lossloss, metricsmetrics) return model def fit(self, X, y): # 验证输入 X, y check_X_y(X, y) self.classes_ unique_labels(y) self.n_classes len(self.classes_) self.input_dim X.shape[1] # 构建模型 self.model_ self._build_model() # 设置EarlyStopping early_stopping EarlyStopping( monitorval_loss, patienceself.patience, restore_best_weightsTrue, # 关键确保返回最佳权重 verbose0 ) # 划分验证集sklearn CV已保证X_train/y_train是子集 # 这里用20%做validation_split避免手动split引入数据泄露 history self.model_.fit( X, y, batch_sizeself.batch_size, epochsself.max_epochs, validation_split0.2, callbacks[early_stopping], verbose0 ) # 记录关键指标 self.best_epoch_ len(history.history[val_loss]) self.val_score_ max(history.history[val_accuracy]) # 或val_loss最小值 return self def predict(self, X): check_array(X) pred_proba self.predict_proba(X) return np.argmax(pred_proba, axis1) def predict_proba(self, X): check_array(X) proba self.model_.predict(X) if self.n_classes 2: # 二分类返回二维数组 [p_neg, p_pos] proba np.hstack([1-proba, proba]) return proba def score(self, X, y): sklearn评分接口返回验证集准确率 from sklearn.metrics import accuracy_score y_pred self.predict(X) return accuracy_score(y, y_pred) def get_params(self, deepTrue): 必须实现供GridSearchCV调用 return { input_dim: self.input_dim, n_classes: self.n_classes, dense_units: self.dense_units, num_layers: self.num_layers, dropout_rate: self.dropout_rate, l2_lambda: self.l2_lambda, learning_rate: self.learning_rate, optimizer: self.optimizer, batch_size: self.batch_size, patience: self.patience, max_epochs: self.max_epochs } def set_params(self, **params): 必须实现供GridSearchCV调用 for key, value in params.items(): setattr(self, key, value) return self这段代码的关键设计点restore_best_weightsTrue这是生命线。没有它模型返回的是最后epoch的权重而最后epoch可能已经过拟合。有了它模型永远返回验证集上loss最低的那个快照。validation_split0.2而非validation_dataGridSearchCV在做k-fold时会把数据切分成k份每次用k-1份训练、1份验证。如果我们在Wrapper里再手动split会导致数据重复使用或泄露。validation_split让Keras在训练集内部划分完全隔离于CV逻辑。score()方法返回accuracy_scoresklearn的GridSearchCV默认用estimator.score()评估所以这里必须返回一个标量分数。你也可以改成f1_score只需在make_scorer里指定。get_params/set_params的完整实现这是GridSearchCV能工作的前提。很多教程漏掉set_params导致搜索时报错TypeError: __init__() got an unexpected keyword argument。3.2 参数空间定义用字典还是分布RandomizedSearchCV为何更实用GridSearchCV是穷举RandomizedSearchCV是采样。对于深度学习后者几乎总是更优计算效率假设你有5个参数每个3个取值GridSearchCV要跑3⁵243次训练。而RandomizedSearchCV设n_iter50只跑50次却大概率覆盖到优质区域。连续参数友好学习率learning_rate是连续值GridSearchCV只能给[1e-3,1e-4,1e-5]而RandomizedSearchCV可以用loguniform(1e-5, 1e-2)在对数尺度上均匀采样更符合深度学习调参直觉。我推荐的参数空间定义方式from scipy.stats import loguniform, randint from sklearn.model_selection import RandomizedSearchCV param_dist { dense_units: randint(64, 512), # 整数均匀分布 num_layers: randint(1, 4), # 1~3层 dropout_rate: [0.2, 0.3, 0.5], # 分类参数用列表 l2_lambda: loguniform(1e-6, 1e-3), # 对数均匀分布 learning_rate: loguniform(1e-5, 1e-2), # 同上 optimizer: [adam, rmsprop], batch_size: [16, 32, 64] } # 注意patience和max_epochs不参与搜索它们是训练策略控制参数 wrapper KerasClassifierWrapper(patience10, max_epochs200) search RandomizedSearchCV( estimatorwrapper, param_distributionsparam_dist, n_iter60, # 采样60次 cv3, # 3折交叉验证 scoringf1_macro, # 使用宏F1作为评分标准 n_jobs1, # 深度学习训练不建议多进程易OOM verbose1, random_state42 ) search.fit(X_train, y_train)实操心得n_jobs1是铁律。Keras模型训练本身是GPU密集型多进程会争抢显存导致ResourceExhaustedError。真要加速应该用tf.data优化数据管道而不是开多进程。3.3 交叉验证策略StratifiedKFold为何是默认选择sklearn的cv参数可以是数字如cv3、KFold对象或StratifiedKFold对象。对于分类任务必须用StratifiedKFold问题场景某医疗诊断数据集正样本患病仅占5%。用普通KFold某一fold可能一个正样本都没有导致fit()时y只有0类模型无法学习区分能力score()返回0整个搜索过程崩溃。解决方案StratifiedKFold保证每一fold中正负样本比例与原始数据集一致。代码只需一行from sklearn.model_selection import StratifiedKFold cv_strategy StratifiedKFold(n_splits3, shuffleTrue, random_state42) search RandomizedSearchCV(estimatorwrapper, cvcv_strategy, ...)额外技巧自定义评分函数处理类别不平衡如果你的业务更关注“查全率”可以这样定义from sklearn.metrics import recall_score from sklearn.metrics import make_scorer recall_scorer make_scorer( recall_score, averagemacro, greater_is_betterTrue ) search RandomizedSearchCV(scoringrecall_scorer, ...)4. 实操过程与核心环节实现4.1 完整端到端流程从数据准备到结果解读下面是一个可直接运行的完整流程基于经典的make_classification生成的模拟数据但每一步都对应真实项目中的关键决策点import numpy as np import pandas as pd from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split, RandomizedSearchCV, StratifiedKFold from sklearn.metrics import classification_report, confusion_matrix from scipy.stats import loguniform, randint # 步骤1生成模拟数据真实项目中替换为pd.read_csv X, y make_classification( n_samples5000, n_features20, n_informative15, n_redundant5, n_classes3, n_clusters_per_class1, random_state42 ) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) # 步骤2定义参数空间重点连续参数用loguniform离散用randint/列表 param_dist { dense_units: randint(64, 256), num_layers: randint(1, 3), dropout_rate: [0.2, 0.3], l2_lambda: loguniform(1e-5, 1e-3), learning_rate: loguniform(1e-4, 1e-2), optimizer: [adam], batch_size: [32, 64] } # 步骤3初始化Wrapper注意patience/max_epochs是固定策略参数 wrapper KerasClassifierWrapper( patience10, max_epochs150 ) # 步骤4配置RandomizedSearchCV cv_strategy StratifiedKFold(n_splits3, shuffleTrue, random_state42) search RandomizedSearchCV( estimatorwrapper, param_distributionsparam_dist, n_iter40, # 40次采样平衡时间与效果 cvcv_strategy, scoringf1_macro, # 宏F1对多分类公平 n_jobs1, # 单进程防OOM verbose2, # 详细日志看到每个trial进度 random_state42, return_train_scoreTrue # 返回训练集分数用于分析过拟合 ) # 步骤5执行搜索真实项目中建议加try-except捕获OOM print(开始超参数搜索...) search.fit(X_train, y_train) print(搜索完成) # 步骤6输出最佳参数与分数 print(\n 最佳参数 ) print(search.best_params_) print(f\n 最佳交叉验证分数 ) print(fMacro F1: {search.best_score_:.4f}) # 步骤7用最佳参数模型在测试集上评估 best_model search.best_estimator_ y_pred best_model.predict(X_test) print(\n 测试集分类报告 ) print(classification_report(y_test, y_pred)) # 步骤8保存结果到CSV工程必备 results_df pd.DataFrame(search.cv_results_) results_df.to_csv(hyperparameter_search_results.csv, indexFalse) print(\n详细结果已保存至 hyperparameter_search_results.csv)关键输出解读search.best_params_这是你交付给客户的“黄金参数”必须记录。例如{batch_size: 64, dense_units: 192, dropout_rate: 0.2, l2_lambda: 0.000234, learning_rate: 0.00456, num_layers: 2, optimizer: adam}search.cv_results_这是一个包含100列的DataFrame其中关键列有param_*每个trial的参数值mean_test_score3折CV的平均分数std_test_score3折CV的标准差越小越稳定rank_test_score排名1为最优mean_fit_time平均训练时间秒mean_score_time平均评分时间实操心得我习惯用results_df.sort_values(mean_test_score, ascendingFalse).head(10)看Top10组合不只看第一名。有时第二名分数只差0.001但训练时间快40%这种trade-off在生产环境中更有价值。4.2 结果可视化用Pandas和Matplotlib快速诊断搜索完成后别急着交报告先用两段代码做快速诊断# 可视化1学习率 vs 分数散点图看是否搜到合理范围 import matplotlib.pyplot as plt import seaborn as sns results_df pd.DataFrame(search.cv_results_) plt.figure(figsize(10, 6)) sns.scatterplot(dataresults_df, xparam_learning_rate, ymean_test_score, alpha0.7) plt.xscale(log) plt.xlabel(Learning Rate (log scale)) plt.ylabel(Mean CV F1 Score) plt.title(Learning Rate Impact on Model Performance) plt.grid(True) plt.show() # 可视化2参数重要性热力图用seaborn pairplot简化 # 只选数值型参数 numeric_params [param_dense_units, param_l2_lambda, param_learning_rate] # 将loguniform参数转为log值便于观察 results_df[log_lr] np.log10(results_df[param_learning_rate].astype(float)) results_df[log_l2] np.log10(results_df[param_l2_lambda].astype(float)) sns.pairplot( results_df, x_vars[param_dense_units, log_l2, log_lr], y_vars[mean_test_score], height4, aspect1.2 ) plt.suptitle(Parameter Sensitivity Analysis, y1.02) plt.show()这两张图能立刻告诉你如果学习率散点图显示“分数随lr增大而单调下降”说明你设置的上限太小应该把loguniform(1e-5, 1e-2)改成loguniform(1e-5, 1e-1)。如果param_dense_units和mean_test_score没明显趋势说明网络容量已足够再加大dense_units只会增加过拟合风险可以固定为128把算力留给其他参数。4.3 生产环境部署如何把搜索结果固化为可复现的Pipeline搜索只是第一步真正交付需要把它变成可复现的Pipeline。我的标准做法是导出最佳参数为JSONimport json with open(best_params.json, w) as f: json.dump(search.best_params_, f, indent2)构建确定性训练脚本train_final.py# 读取最佳参数 with open(best_params.json) as f: best_params json.load(f) # 构建最终模型不走Wrapper直接Keras model build_model(**best_params) # 你的create_model函数 model.fit(X_train_full, y_train_full, validation_split0.2, epochsbest_params[max_epochs], # 这里可设更大值 callbacks[EarlyStopping(patience20)]) model.save(final_model.h5)Docker化推理服务Dockerfile片段FROM tensorflow/tensorflow:2.12.0-gpu COPY requirements.txt . RUN pip install -r requirements.txt COPY final_model.h5 /app/model.h5 COPY inference.py /app/inference.py CMD [python, inference.py]这样从搜索到部署全程参数可控、过程可审计、结果可复现。某公司用这套流程把模型迭代周期从“两周一次”压缩到“两天一次”因为新数据进来后只需重新运行train_final.py无需再搜参。5. 常见问题与排查技巧实录5.1 典型报错与根因分析速查表报错信息根本原因解决方案我的实测耗时ValueError: Input 0 of layer dense is incompatible with the layer: expected axis -1 of input shape to have value X, but received input with shape [None, Y]input_dim未正确传入Wrapper或X_train形状在CV中被意外改变在fit()开头加print(X.shape, y.shape)确保X_train是2D数组非pandas DataFrame用.values转换15分钟ResourceExhaustedError: OOM when allocating tensor with shape...n_jobs1导致多进程争抢GPU显存或batch_size过大立即设n_jobs1将batch_size从64降到32检查n_iter是否设得过大如2005分钟改完立刻好TypeError: __init__() got an unexpected keyword argument param_xxxWrapper类缺少set_params()方法或get_params()返回字典键名与param_dist不一致检查set_params()是否遍历params.items()确认param_dist键名如param_dropout_rate与get_params()返回键应为dropout_rate一致20分钟新手常踩ValueError: Cant handle mix of binary and continuous targetsy标签是float类型如[0.0,1.0]但Keras期望int在fit()中加y y.astype(int)或生成数据时用make_classification(..., dtypeint)10分钟UserWarning: Your CPU supports instructions that this TensorFlow binary was not compiled to use: AVX2 FMACPU指令集警告不影响结果但降低训练速度重装支持AVX2的TensorFlowpip uninstall tensorflow pip install tensorflow-cpu仅限CPU环境30分钟需重装注意所有这些报错我在带新人时都遇到过。最常被忽略的是pandas DataFrame传入问题。X_train如果是DataFrameX.shape可能是(5000,)而非(5000,20)因为DataFrame的shape返回的是(rows,)而X.values.shape才是(rows, cols)。务必在fit()开头加assert len(X.shape) 2断言。5.2 性能瓶颈定位三步法快速判断是CPU、GPU还是IO瓶颈当搜索变慢不要盲目加机器先用三步法定位第一步看GPU利用率nvidia-smi如果GPU-Util持续30%说明GPU没吃饱瓶颈在CPU或IO。如果GPU-Util 90%且显存占用80%说明GPU计算饱和可考虑升级GPU。如果GPU-Util 50%但显存占用100%说明数据加载太慢GPU在等数据。第二步监控CPU和内存htop如果CPU使用率50%说明Python主线程空闲可能卡在model.fit()的callback里。如果内存占用90%说明n_iter太大或batch_size太大需降级。第三步加计时器到关键路径import time start time.time() model.fit(X, y, ...) # 训练 print(fTraining time: {time.time()-start:.2f}s) start time.time() pred model.predict(X_test) # 预测 print(fPredict time: {time.time()-start:.2f}s)如果训练时间远大于预测时间如100s vs 0.1s说明模型复杂度高应减少dense_units或num_layers。如果预测时间异常长1s说明模型输出层太大检查n_classes是否误设为1000实际只有3类。5.3 经验避坑清单那些文档里不会写的细节坑1EarlyStopping的monitor选择不要用val_accuracy它在类别不平衡时波动大。一律用val_loss因为loss是连续可微的更稳定。我在一个电商点击率预测项目中把monitor从val_accuracy换成val_loss搜索稳定性提升40%。坑2RandomizedSearchCV的random_state陷阱RandomizedSearchCV(random_state42)只控制参数采样顺序不控制模型内部随机性如权重初始化。必须在fit()前加import tensorflow as tf import numpy as np tf.random.set_seed(42) np.random.seed(42)否则同一组参数两次搜索结果可能不同。坑3类别数动态推断的隐患Wrapper中self.n_classes len(unique_labels(y))看似合理但如果y_train里恰好缺某个类别如3分类数据某fold只有2类unique_labels(y)返回2模型输出层就变成2维后续predict_proba()会出错。解决方案在__init__()中强制传入n_classes或在fit()中用np.max(y)1要求y从0开始编码。坑4模型保存的路径陷阱model.save(model.h5)在分布式环境下可能冲突。正确做法是每个trial用唯一IDimport uuid model_path fmodels/trial_{uuid.uuid4().hex[:8]}.h5 model.save(model_path)最后分享一个小技巧搜索结束后别急着删临时模型文件。我把所有trial_*.h5打包成zip命名为search_artifacts_20231015.zip和best_params.json一起归档。半年后客户问“当时为什么选dropout0.3”我直接解压一个trial模型用model.summary()展示网络结构用history.history画出loss曲线——比任何文字报告都有说服力。