1. 项目概述为什么我们需要自己动手造轮子在实际做机器学习项目时你有没有遇到过这种场景手头的数据不是标准的“表格标签”格式而是带着一堆业务逻辑的原始数据模型输出的不是冷冰冰的数字而是需要翻译成业务人员能看懂的“高性价比”“中等风险”“需重点跟进”这类标签整个流程里数据清洗、特征构造、目标转换、模型训练、结果解释环环相扣但sklearn自带的StandardScaler、OneHotEncoder、Pipeline又像一套标准化的乐高积木——好用但拼不出你真正想要的那个形状。这就是我写这篇内容的出发点。它不是教你怎么调RandomForestClassifier的n_estimators也不是讲GridSearchCV怎么写参数网格。它讲的是当你发现现成的工具箱里缺一把特定尺寸的螺丝刀时如何亲手打一把出来并把它严丝合缝地装进你自己的流水线里。核心关键词就是“Customizing sk-learn Models and Pipelines”——定制化。这里的“定制”不是指改源码而是严格遵循sklearn的API契约fit,transform,predict,predict_proba用Python原生类封装你的业务逻辑让它和LogisticRegression一样能被Pipeline无缝调用能被cross_val_score直接评估能被joblib一键保存。我用加州房价数据集做例子但目的不是预测房价本身而是解决一个更贴近真实业务的问题把连续的房价比如1.23代表123,000美元转化成三个有明确业务含义的价格带——“经济型≤10万”、“改善型10–20万”、“高端型20万”然后训练一个分类模型来精准识别这些价格带。这个过程里你需要的不是一个回归器而是一个能理解“价格带”语义的分类器你不需要原始经纬度而需要能反映区域聚集效应的“地理簇编码”你甚至希望模型输出的概率列名直接是“经济型”“改善型”“高端型”而不是class_0、class_1。这些需求sklearn没有现成的PriceBandClassifier或GeoClusterTransformer但它的设计哲学——“一切皆Estimator”——给了你亲手实现它们的全部自由和规范。这篇文章就是一份从零开始的“定制化开发手册”。它不假设你精通面向对象编程但要求你愿意打开.py文件看看sklearn.base里BaseEstimator和TransformerMixin到底干了什么。我会带你一行行写完Price_LabelHandler、Price_Classifier、FeatureEngineering和PricePipeline这四个核心类解释每一个self变量为什么存在每一步fit和transform的输入输出是什么以及最关键的——当你的自定义类和XGBoost、Random Forest这些“大厂出品”的模型混在一起跑的时候底层发生了什么。如果你正卡在“想法很清晰代码写不出来”或者“代码写出来了但Pipeline报错说‘object has no attribute transform’”的阶段那接下来的内容就是为你准备的。2. 核心设计思路契约精神与模块解耦2.1 sklearn的“契约”到底是什么很多初学者觉得自定义类很难根源在于没吃透sklearn最核心的设计哲学契约Contract。它不关心你内部怎么实现只强制要求你对外提供一套标准接口。这套接口就是fit、transform对Transformer、predict对Estimator、predict_proba对Classifier这几个方法。只要你实现了它们并且行为符合约定sklearn就认你为“自己人”。这个契约具体体现在三份“协议”上BaseEstimator这是所有estimator的基类。它自动给你提供了get_params()和set_params()方法让你的类天然支持GridSearchCV的超参搜索。你不需要自己写只要继承它param_grid{classifier__max_depth: [3,5,7]}就能直接用。TransformerMixin这是所有数据变换器的基类。它只干一件事给你一个默认的fit_transform(self, X, yNone)方法其内部就是self.fit(X, y).transform(X)。这意味着你只要写了fit和transformfit_transform就自动有了省去重复劳动。ClassifierMixin这是所有分类器的基类。它给你加了一个关键属性classes_。这个属性必须在fit方法里被赋值通常是np.unique(y)的结果。它是predict_proba返回概率矩阵列顺序的唯一依据也是classification_report能正确计算指标的前提。所以我的设计第一步就是给每个类“认祖归宗”。Price_LabelHandler不继承任何东西因为它只是个纯业务逻辑的工具类不参与Pipeline的流程。但Price_Classifier必须继承BaseEstimator和ClassifierMixinFeatureEngineering必须继承BaseEstimator和TransformerMixinPricePipeline则直接继承sklearn.pipeline.Pipeline。这不是为了炫技而是为了获得“免检资格”——让sklearn框架自动信任你、调度你、评估你。2.2 为什么要把Labeling、Predictor、Feature Engineering拆成三个独立模块看到原始代码里Price_LabelHandler、Price_Classifier、FeatureEngineering三个类你可能会想“干嘛不全塞进一个PriceModel里省事” 这是个典型的“眼前省事长远踩坑”的想法。模块解耦的价值在于应对未来不可预知的变化。想象一下这三个独立变化的场景业务规则变老板明天说“经济型”门槛从10万提到15万“高端型”从20万提到25万。这时你只需要修改Price_LabelHandler的thresholds参数Price_Classifier和FeatureEngineering完全不用动。如果它们混在一起你得在几百行代码里大海捞针找阈值。模型算法变A/B测试发现XGBoost在价格带上过拟合换成LightGBM效果更好。这时你只需要把PricePipeline(thresholds, classifier, n_cluster)里的classifier参数从XGBClassifier换成LGBMClassifier其他代码零改动。如果预测逻辑硬编码在类里你得重写整个predict方法。数据源变公司新接入了卫星图像数据需要加入新的地理特征比如到最近地铁站的距离。这时你只需要扩展FeatureEngineering.transform方法增加几行计算新特征的代码Price_LabelHandler和Price_Classifier依然稳如泰山。这种“高内聚、低耦合”的设计本质上是在模拟软件工程里的“单一职责原则”。Price_LabelHandler只负责“价格↔ID↔标签”的映射它不应该知道KMeans怎么聚类FeatureEngineering只负责“把原始数据变成模型能吃的特征”它不应该知道分类器要预测几个类别Price_Classifier只负责“用给定的特征X预测出类别y”它不应该关心特征是怎么算出来的。当每个模块都只做一件事并且做好这件事整个系统的可维护性、可测试性、可复用性就指数级提升。我在实际项目里见过太多“超级大类”一个文件上千行改一个bug牵一发而动全身最后团队新人根本不敢碰。定制化不是为了炫技而是为了在复杂业务中守住代码的清晰边界。2.3 Pipeline的继承 vs 组合为什么选择class PricePipeline(Pipeline)原始代码里PricePipeline是通过继承sklearn.pipeline.Pipeline来实现的。你可能会疑惑“为啥不直接用Pipeline(steps[...])非得搞个新类” 这背后是两个截然不同的设计意图。直接使用Pipeline(steps[...])这是一种“组合”Composition模式。你把FeatureEngineering和Price_Classifier当作零件组装成一个流水线。它的优点是极其简单适合快速验证。缺点是它无法携带额外的、与流程强相关的状态信息。比如thresholds这个列表是Price_LabelHandler的命脉但它不属于任何一个step的参数。你没法把它优雅地塞进steps里只能靠全局变量或者外部传参非常不安全。继承Pipeline并创建PricePipeline这是一种“继承”Inheritance模式。你不是在组装零件而是在定义一种全新的、有自己身份的“机器”。PricePipeline这个类本身就代表了“一个用于价格带分类的端到端解决方案”。因此thresholds、n_cluster、classifier这些对整个流程至关重要的参数顺理成章地成为它的__init__参数和实例属性self.thresholds,self.n_cluster。这带来的好处是巨大的语义清晰xgb_pipe PricePipeline([1,2], XGBClassifier(), 10)这行代码比pipe Pipeline([(fe, FeatureEngineering(10)), (clf, Price_Classifier([1,2], XGBClassifier()))])更直白地表达了“我要建一个价格带管道”这个意图。状态统一所有影响这个管道行为的参数都在一个地方定义、一个地方管理。你想序列化保存它joblib.dump(xgb_pipe, price_pipe.pkl)就够了thresholds和n_cluster会随着xgb_pipe一起被保存不会丢失。扩展性强未来你想加一个log_training_stats()方法记录每次fit时的训练集大小、特征维度这个方法天然属于PricePipeline这个“实体”而不是某个孤立的step。当然继承不是银弹。它增加了类的复杂度。但在本例中PricePipeline的核心价值恰恰在于它封装了“价格带分类”这一完整业务概念的所有必要参数和行为。此时继承带来的语义优势和工程优势远大于它引入的一点点复杂度。这也是为什么sklearn自己也大量使用继承——LinearRegression继承BaseEstimator和RegressorMixinPCA继承BaseEstimator和TransformerMixin——因为它们各自代表了一种明确的、可复用的“机器类型”。3. 核心细节解析从Price_LabelHandler到PricePipeline的逐行深挖3.1Price_LabelHandler业务规则的纯粹封装这个类看起来最简单只有几十行但它却是整个定制化链条的基石。它的使命只有一个把业务语言“经济型”“改善型”和机器语言0, 1, 2之间架起一座精确、无歧义的翻译桥。我们来逐行拆解它的精妙之处。class Price_LabelHandler(): def __init__(self, thresholds): # sort thresholds self.thresholds sorted(thresholds) # convert thresholds to labels self.labels [fprice {self.thresholds[0]}] for low, high in zip(self.thresholds[:-1], self.thresholds[1:]): self.labels.append(f{low} price {high}) self.labels.append(f{high} price) # initialise ids for each class self.ids range(len(self.labels))第一眼你可能觉得self.ids range(len(self.labels))有点多余直接用range(len(...))不就行了这里的关键在于确定性。range对象是惰性的它本身不存储所有数字只存起点、终点、步长。但self.ids在这里是作为一个“ID集合”的标识符存在的。它保证了id_to_label(0)永远对应第一个标签id_to_label(1)永远对应第二个无论labels列表后面怎么变虽然这里不会变。这是一种防御性编程让类的内部状态更清晰、更可控。再看price_to_id方法def price_to_id(self, price): for threshold, id in zip(self.thresholds, self.ids[:-1]): if price threshold: return id return self.ids[-1]这个循环的写法是经过深思熟虑的。它没有用np.digitize或者pd.cut原因有二一是为了绝对的透明和可控避免第三方库的黑盒行为二是为了处理边界情况。注意self.ids[:-1]它取的是除了最后一个ID之外的所有ID。这意味着只有当price小于等于某个阈值时才返回对应的ID如果price比所有阈值都大就兜底返回self.ids[-1]即最后一个ID。这个逻辑完美对应了我们定义的三个区间1,(1,2],2。它不像digitize那样可能产生0或len(bins)的越界索引而是严格遵循了业务定义的“左闭右开”或“左开右闭”区间。最后id_to_label方法def id_to_label(self, id): return self.labels[id]它看起来像一句废话但它的价值在于解耦。Price_Classifier.predict方法里模型输出的是id比如1但最终要返回给用户的是label比如1 price 2。如果Price_Classifier自己去硬编码这个映射那么一旦Price_LabelHandler的labels生成逻辑变了Price_Classifier也得跟着改。现在Price_Classifier只需要调用self.labeler.id_to_label(id)它就完全不知道labels是怎么来的只知道“给我ID我还你Label”。这就是面向接口编程的力量。提示Price_LabelHandler是一个纯函数式工具类它没有fit方法也不需要fit。它的所有状态thresholds,labels,ids都在__init__时确定。这意味着你可以安全地在多个Pipeline实例间共享同一个Price_LabelHandler实例而不用担心状态污染。3.2Price_Classifier让任意分类器“懂业务”的适配器如果说Price_LabelHandler是翻译官那么Price_Classifier就是一位“外交官”。它的核心任务是把一个通用的、只认识数字ID的分类器比如XGBClassifier包装成一个能理解“价格带”业务语义的智能体。它不改变分类器的内在能力只是改变了它与外界沟通的语言。我们先看它的初始化def __init__(self, thresholds, classifier): self.classifier classifier # initialize labeler self.labeler Price_LabelHandler(thresholds)这里classifier是一个已经实例化的对象比如XGBClassifier(objectivemulti:prob)。Price_Classifier并不关心它是什么只把它当作一个黑盒的“预测引擎”。self.labeler则是前面定义好的翻译官。这两者的结合构成了一个完整的“业务感知”单元。fit方法是整个适配器的灵魂def fit(self, X, y): # convert prices to IDs id y.map(self.labeler.price_to_id) # fit classifier self.classifier.fit(X, id) return self注意y.map(self.labeler.price_to_id)这一行。y是原始的连续房价数组[1.23, 2.45, 0.89, ...]map操作将它逐元素地转换成了ID数组[0, 2, 0, ...]。这个转换是fit过程的第一步也是最关键的一步。它确保了分类器接收到的永远是它能理解的整数标签。self.classifier.fit(X, id)之后分类器内部的classes_属性就被正确设置了比如[0, 1, 2]这为后续的predict_proba奠定了基础。predict方法同样简洁有力def predict(self, X): # predict IDs id self.classifier.predict(X) # convert to labels return np.array([self.labeler.id_to_label(i) for i in id])模型输出id我们用翻译官id_to_label把它变回label。这里用np.array包裹是为了保证返回值的类型和sklearn的predict方法一致一个一维numpy数组这样下游的classification_report才能正常工作。最体现功力的是predict_probadef predict_proba(self, X): # predict probabilities probas self.classifier.predict_proba(X) # get labels labels [self.labeler.id_to_label(i) for i in self.classifier.classes_] # return as dataframe return pd.DataFrame(probas, columnslabels)probas是一个二维numpy数组形状是(n_samples, n_classes)。self.classifier.classes_是一个一维数组比如[0, 1, 2]它严格定义了probas每一列所对应的类别。我们用self.labeler.id_to_label(i)把[0,1,2]映射成[price 1, 1 price 2, 2 price]然后把这个列表作为pd.DataFrame的columns参数。结果就是一个列名是业务标签、数值是概率的DataFrame。用户拿到这个结果一眼就能看懂“这栋房子有92%的概率属于经济型”而不需要查表翻译。这个设计把技术细节概率矩阵和业务价值可解释的预测完美地融合在了一起。注意Price_Classifier必须同时继承BaseEstimator和ClassifierMixin。ClassifierMixin会自动给它加上classes_属性但这个属性的值是由它内部的self.classifier在fit时设置的。所以Price_Classifier自己不需要在fit里手动设置self.classes_它信任self.classifier会做好这件事。这是继承契约带来的便利。3.3FeatureEngineering超越StandardScaler的领域知识注入FeatureEngineering类是整个定制化中最能体现“领域知识”的部分。它证明了特征工程绝不仅仅是StandardScaler和OneHotEncoder的排列组合而是将你对业务数据的深刻理解编码成可执行的Python逻辑。它的初始化很简单def __init__(self, n_cluster10): # set number of clusters self.n_cluster n_clustern_cluster是唯一的超参数它决定了地理特征的粒度。10个簇意味着把整个加州的地理空间粗略地划分成10个区域。这个数字不是拍脑袋定的而是基于经验太少比如3个会丢失区域差异太多比如100个会导致过拟合且每个簇的样本量太少。fit方法是关键def fit(self, data_df, _): # fit kmeans clustering self.kmeans KMeans(n_clustersself.n_cluster, random_state0, n_initauto) self.kmeans.fit(data_df[[Latitude,Longitude]]) # predict and one-hot-encode labels geo_labels self.kmeans.predict(data_df[[Latitude,Longitude]]) self.enc OneHotEncoder().fit(geo_labels.reshape(-1,1)) return self这里有两个重要细节。第一fit方法的第二个参数是_下划线而不是y。这是因为TransformerMixin的fit方法签名是fit(self, X, yNone)y是可选的。我们这里明确地忽略它用_表示因为我们不需要目标变量来学习特征变换。第二self.kmeans.fit(...)和self.enc.fit(...)都是在fit里完成的。kmeans学习的是地理空间的聚类中心OneHotEncoder学习的是这n_cluster个簇ID的编码方式。这两个模型都是FeatureEngineering这个“特征工厂”的核心资产必须在fit时就训练好才能在transform时稳定复用。transform方法则是真正的“生产”环节def transform(self, data_df): # bypass features feature_cols [MedInc,HouseAge,AveRooms,AveBedrms,Population,AveOccup] feature_df data_df[feature_cols].reset_index(dropTrue) # number of bedrooms over total number of rooms feature_df[bedrms_per_room] (feature_df[AveBedrms] / feature_df[AveRooms]) # encode geo-location geo_labels self.kmeans.predict(data_df[[Latitude,Longitude]]) geo_matrix self.enc.transform(geo_labels.reshape(-1,1)).toarray() col_names [fCluster_{i} for i in range(self.n_cluster)] cluster_df pd.DataFrame(geo_matrix, columnscol_names) return feature_df.join(cluster_df)这段代码可以看作一个微型的ETLExtract-Transform-Load流程Extract抽取data_df[feature_cols]抽取了6个原始数值特征。Transform转换bedrms_per_room AveBedrms / AveRooms计算了一个新的、有业务意义的比率特征。这个比率比单独的AveBedrms或AveRooms更能反映房屋的“紧凑程度”或“居住舒适度”这是模型无法自己学到的领域知识。Encode编码self.kmeans.predict对经纬度进行聚类得到每个样本所属的簇IDself.enc.transform将这些ID转为one-hot向量。toarray()是必须的因为OneHotEncoder默认返回稀疏矩阵而大多数分类器如XGBoost期望稠密的numpy数组作为输入。Load加载feature_df.join(cluster_df)将原始特征和新生成的地理簇特征拼接在一起形成最终的、模型可食用的特征矩阵。这个过程把地理坐标这种高维、难以直接建模的原始数据转化成了10个清晰、离散、有聚类意义的二元特征Cluster_0到Cluster_9。模型可以轻松地学习到“Cluster_5”这个区域的房价普遍偏高而不需要理解经纬度的数学含义。这就是特征工程的魔力。实操心得在transform方法里我用了data_df[[Latitude,Longitude]]而不是X[:, [0,1]]。这是因为FeatureEngineering是为加州房价数据集定制的它明确知道Latitude和Longitude是第0和第1列。但在更通用的场景下你应该用X的列名或索引来获取以保证鲁棒性。另外AveRooms可能为0导致除零错误。在生产环境你必须加上np.where(feature_df[AveRooms] 0, ..., 0)这样的保护逻辑。本文为聚焦核心做了简化。3.4PricePipeline端到端流程的终极封装PricePipeline是整个定制化项目的皇冠。它把前面所有的“零件”——FeatureEngineering特征工厂、Price_Classifier业务感知分类器——组装成一台能直接投入生产的“机器”。它的继承关系是关键class PricePipeline(Pipeline): def __init__(self, thresholds, classifier, n_cluster10): # set attributes self.thresholds thresholds self.n_cluster n_cluster self.classifier classifier # initialize feature engineering and price classifier fe FeatureEngineering(n_cluster) price_classifier Price_Classifier(thresholds, classifier) # define pipeline steps steps [(transformer, fe), (model, price_classifier)] # initialize super class super(PricePipeline, self).__init__(stepssteps)super(PricePipeline, self).__init__(stepssteps)这一行是魔法发生的地方。它调用了父类Pipeline的__init__方法把steps列表传递进去。Pipeline的__init__会做一系列事情它会检查每个step是否是合法的estimator会把(transformer, fe)绑定到self.transformer把(model, price_classifier)绑定到self.model最重要的是它会自动为你实现fit、transform、predict、predict_proba等所有方法。这些方法的内部逻辑就是严格按照steps的顺序依次调用每个step的对应方法。例如当你调用xgb_pipe.fit(X_train, y_train)时Pipeline内部会调用self.transformer.fit(X_train, y_train)→ 执行FeatureEngineering.fit调用self.model.fit(self.transformer.transform(X_train), y_train)→ 先transform得到特征再fit分类器当你调用xgb_pipe.predict(X_test)时Pipeline内部会调用self.transformer.transform(X_test)→ 得到特征矩阵调用self.model.predict(特征矩阵)→ 得到业务标签你完全不需要在PricePipeline里重写这些方法。Pipeline的继承让你免费获得了整个流程的编排能力。你付出的代价只是在__init__里定义好steps以及把业务参数thresholds,n_cluster作为实例属性保存下来。这种“声明式”的编程风格极大地提升了代码的可读性和可维护性。提示PricePipeline的steps列表里transformer和model是step的名字name。这个名字非常重要因为它构成了Pipeline的“参数路径”。比如如果你想用GridSearchCV搜索FeatureEngineering的n_cluster参数你的param_grid就必须写成{transformer__n_cluster: [5, 10, 15]}。双下划线__是分隔符前面是step name后面是该step类的参数名。这是Pipeline能实现嵌套参数搜索的底层机制。4. 完整实操过程从数据加载到模型评估的每一步4.1 环境准备与依赖安装在开始编码之前确保你的Python环境已经安装了所有必需的库。我推荐使用conda来管理环境因为它能更好地处理科学计算库的二进制依赖。# 创建一个名为 sklearn-custom 的新环境 conda create -n sklearn-custom python3.9 conda activate sklearn-custom # 安装核心库 pip install scikit-learn pandas numpy matplotlib seaborn pip install xgboost lightgbm # 可选安装jupyter用于交互式探索 pip install jupyter验证安装是否成功import sklearn import pandas as pd import numpy as np print(fscikit-learn version: {sklearn.__version__}) print(fpandas version: {pd.__version__})注意XGBoost的安装有时会因系统不同而失败。如果pip install xgboost报错可以尝试conda install -c conda-forge xgboost。lightgbm同理。版本兼容性很重要本文所有代码均在scikit-learn1.3.0、xgboost2.0.3、pandas2.0.3环境下测试通过。4.2 数据加载、探索与预处理我们使用sklearn内置的加州房价数据集。它是一个经典的回归数据集非常适合用来演示“回归转分类”的定制化流程。from sklearn import datasets from sklearn.model_selection import train_test_split import pandas as pd import numpy as np # 加载数据as_frameTrue 返回 pandas DataFrame便于操作 data datasets.fetch_california_housing(as_frameTrue) data_df, target data[data], data[target] print(原始数据形状:, data_df.shape) print(目标变量房价单位10万美元前5个值:, target.head().values) # 查看数据的基本信息 print(\n数据集基本信息:) print(data_df.info()) print(\n目标变量统计:) print(target.describe())输出会显示数据集有20640个样本8个特征包括Latitude和Longitude目标变量target是连续的房价单位是10万美元。target.describe()会告诉你房价的均值大约是2.07即20.7万美元最小值0.151.5万美元最大值5.0050万美元。这为我们设定价格带阈值提供了依据。接下来我们进行一次标准的训练/测试分割# 分割数据test_size0.2 表示20%的数据作为测试集 X_train, X_test, y_train, y_test train_test_split( data_df, target, test_size0.2, random_state42 ) print(f\n训练集大小: {X_train.shape}) print(f测试集大小: {X_test.shape})4.3 定义并初始化定制化组件现在我们将前面分析过的四个核心类完整地、可运行地写出来。请将以下代码复制到你的Python脚本或Jupyter Notebook中。from sklearn.base import BaseEstimator, TransformerMixin, ClassifierMixin from sklearn.cluster import KMeans from sklearn.preprocessing import OneHotEncoder from sklearn.pipeline import Pipeline import numpy as np import pandas as pd # 1. Price_LabelHandler class Price_LabelHandler(): def __init__(self, thresholds): self.thresholds sorted(thresholds) self.labels [fprice {self.thresholds[0]}] for low, high in zip(self.thresholds[:-1], self.thresholds[1:]): self.labels.append(f{low} price {high}) self.labels.append(f{high} price) self.ids range(len(self.labels)) def price_to_id(self, price): for threshold, id in zip(self.thresholds, self.ids[:-1]): if price threshold: return id return self.ids[-1] def id_to_label(self, id): return self.labels[id] # 2. Price_Classifier class Price_Classifier(BaseEstimator, ClassifierMixin): def __init__(self, thresholds, classifier): self.classifier classifier self.labeler Price_LabelHandler(thresholds) def fit(self, X, y): id y.map(self.labeler.price_to_id) self.classifier.fit(X, id) return self def predict(self, X): id self.classifier.predict(X) return np.array([self.labeler.id_to_label(i) for i in id]) def predict_proba(self, X): probas self.classifier.predict_proba(X) labels [self.labeler.id_to_label(i) for i in self.classifier.classes_] return pd.DataFrame(probas, columnslabels) # 3. FeatureEngineering class FeatureEngineering(BaseEstimator, TransformerMixin): def __init__(self, n_cluster10): self.n_cluster n_cluster def fit(self, data_df, _): self.kmeans KMeans(n_clustersself.n_cluster, random_state0, n_initauto) self.kmeans.fit(data_df[[Latitude,Longitude]]) geo_labels self.kmeans.predict(data_df[[Latitude,Longitude]]) self.enc OneHotEncoder().fit(geo_labels.reshape(-1,1)) return self def transform(self, data_df): feature_cols [MedInc,HouseAge,AveRooms,AveBedrms,Population,AveOccup] feature_df data_df[feature_cols].reset_index(dropTrue) # 添加新特征卧室数/总房间数 feature_df[bedrms_per_room] (feature_df[AveBedrms] / feature_df[AveRooms]) # 地理聚类编码 geo_labels self.kmeans.predict(data_df[[Latitude,Longitude]]) geo_matrix self.enc.transform(geo_labels.reshape(-1,1)).toarray() col_names [fCluster_{i} for i in range(self.n_cluster)] cluster_df pd.DataFrame(geo_matrix, columnscol_names) return feature_df.join(cluster_df) # 4. PricePipeline class PricePipeline(Pipeline): def __init__(self, thresholds, classifier, n_cluster10): self.thresholds thresholds self.n_cluster n_cluster self.classifier classifier fe FeatureEngineering(n_cluster) price_classifier Price_Classifier(thresholds, classifier) steps [(transformer, fe), (model, price_classifier)] super(PricePipeline, self).__init__(stepssteps)4.4 构建、训练并评估第一个价格带管道XGBoost现在我们用具体的参数来实例化PricePipeline并进行训练。from xgboost import XGBClassifier from sklearn.metrics import classification_report, confusion_matrix import matplotlib.pyplot as plt import seaborn as sns # 定义价格带阈值单位10万美元 thresholds [1, 2] # 即10万(10-20]万20万 n_cluster 10 classifier XGBClassifier(objectivemulti:softprob, random_state42, n_estimators100) # 初始化管道 xgb_pipe PricePipeline(thresholds, classifier, n_cluster) # 训练管道 print(正在训练XGBoost价格带管道...) xgb_pipe.fit(X_train, y_train) print(训练完成) # 在测试集上进行预测 y_pred xgb_pipe.predict(X_test) y_pred_proba xgb_pipe.predict_proba(X_test) print(\n预测结果示例前5个:) print(y_pred[:5]) print(\n预测概率示例前5个:) print(y_pred_proba.head()) # 评估模型性能 print(\nXGBoost模型分类报告:) print(classification_report(y_test.map(xgb_pipe.steps[1][1].labeler.price_to_id), y_pred.map(xgb_pipe.steps[1][1].labeler.price_to_id)))这里有一个关键点classification_report需要接收数字ID0,1,2作为输入而不是字符串标签price 1。所以我们用y_test.map(...)和y_pred.map(...)再次调用Price_LabelHandler.price_to_id把原始房价和预测标签都转换回ID