本文为 Python 60 Days Challenge 课程学习整理,原作者与原 Notebook 链接见「课程资料」。代码按章节顺序运行;语法示意和故意报错的反例已单独标注。

数据准备: 将课程仓库中的 data.csv/data.xlsx 放在 Notebook 的当前工作目录,路径已移除原作者电脑的盘符。读取 Excel 需要 openpyxl。各节额外依赖按文中安装说明准备。

文中保留的截图、性能表和数值属于原讲义的历史示例,不是本次修订后的重新训练结果。实际输出受数据、版本、随机种子和训练预算影响。

本次校订: 多目标适应度改为训练集内交叉验证的精确率与召回率;用非支配排序提取第一前沿。selBest 的字典序排序不等于帕累托前沿。图中的基线也来自同样的交叉验证。确定一个折中方案后,才在独立测试集报告最终结果。

打卡任务

  • 阅读本节讲义或 Notebook
  • 跑通示例代码
  • 整理 3 个关键点
  • 完成当日打卡输出

课程资料

学习内容

什么是多目标优化?

在现实生活中,我们经常面临需要同时考虑多个目标的决策问题:

🏠 买房子:既要价格便宜,又要地段好,还要面积大
🚗 买车:既要省油,又要动力强,还要价格合理
📈 投资理财:既要收益高,又要风险低
🤖 机器学习:既要精确率高,又要召回率高

这些目标往往是相互冲突的:

  • 地段好的房子通常更贵
  • 动力强的车往往更费油
  • 高收益投资通常伴随高风险
  • 提高精确率可能会降低召回率

这就是多目标优化问题!

1. 准备工作:数据预处理与基准模型

首先,我们运行你提供的代码。这段代码完成了以下工作:

  1. 加载数据。
  2. 对分类型和连续型特征进行预处理,包括标签编码、独热编码和缺失值填充。
  3. 划分训练集和测试集。
  4. 训练一个默认参数的随机森林模型作为我们的基准(Baseline),以便后续与优化后的模型进行对比。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.base import clone
from sklearn.compose import ColumnTransformer, make_column_selector
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
from sklearn.model_selection import train_test_split, cross_val_score, cross_validate
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, precision_score, recall_score

data = pd.read_csv('data.csv').drop(columns=['Id'], errors='ignore')
# 标签不能作为特征或用填补器补造;这里只排除缺失标签的记录。
data = data.dropna(subset=['Credit Default'])
preprocessor = ColumnTransformer([
    ('numeric', SimpleImputer(strategy='median', keep_empty_features=True),
     make_column_selector(dtype_include=np.number)),
    ('categorical', Pipeline([
        ('imputer', SimpleImputer(strategy='most_frequent', keep_empty_features=True)),
        ('onehot', OneHotEncoder(handle_unknown='ignore', sparse_output=False))]),
     make_column_selector(dtype_exclude=np.number))
])

def make_classifier(model):
    # 把填补和编码放进模型流水线,每个交叉验证折只在本折训练数据上拟合。
    return Pipeline([('preprocess', clone(preprocessor)), ('model', model)])
X = data.drop(columns=['Credit Default'])
y = data['Credit Default']
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y)
import time
rf_model = make_classifier(RandomForestClassifier(random_state=42))
rf_model.fit(X_train, y_train)
rf_pred = rf_model.predict(X_test)
print(classification_report(y_test, rf_pred, zero_division=0))
print(confusion_matrix(y_test, rf_pred))

2. 什么是多目标优化?

在很多现实问题中,我们往往需要同时优化多个目标,而这些目标常常是相互冲突的。例如:

  • 在投资中,我们希望 收益率最高,同时 风险最低。
  • 在制造业中,我们希望 产品质量最好,同时 生产成本最低。
  • 在机器学习中,我们希望模型的 精确率(Precision) 和 召回率(Recall) 都尽可能高。

这种需要同时平衡多个冲突目标的问题,就是 多目标优化(Multi-Objective Optimization) 。

帕累托最优(Pareto Optimality)

与单目标优化不同,多目标优化通常没有唯一的“最佳解”,因为提升一个目标可能会导致另一个目标变差。因此,我们引入了 帕累托最优 的概念。

  • 支配(Dominate) :如果一个解 A 在所有目标上都不比解 B 差,并且至少在一个目标上比解 B 好,那么我们说“解 A 支配解 B”。
  • 帕累托最优解(Pareto Optimal Solution) :如果一个解没有被任何其他解所支配,那么它就是一个帕累托最优解。
  • 帕累托前沿(Pareto Front) :所有帕累托最优解构成的集合。

我们的目标就是找到这个“帕累托前沿”,它代表了在不同目标之间的最佳“权衡”方案。决策者可以根据具体需求,从这个集合中选择一个最合适的解。

3. 使用 DEAP 库实现多目标优化

DEAP (Distributed Evolutionary Algorithms in Python) 是一个强大的Python库,专门用于实现遗传算法、遗传编程、演化策略等演化计算方法。它非常适合解决多目标优化问题。

我们将使用 DEAP 中的 NSGA-II (Non-dominated Sorting Genetic Algorithm II) 算法,这是一种非常经典和高效的多目标遗传算法。

基本步骤如下:

  1. 安装 DEAP:首先需要安装这个库。

  2. 定义问题:

    • 个体(Individual) :定义一个解的结构。在我们的案例中,一个个体就是一组随机森林的超参数(如 n_estimators​, max_depth 等)。
    • 适应度(Fitness) :定义我们的优化目标。在这里,是最大化精确率和召回率。
  3. 创建工具箱(Toolbox) :

    • 评估函数(Evaluation Function) :这是核心部分。它接收一个个体(一组超参数),用这组参数训练一个模型,然后在测试集上评估其表现,并返回适应度(精确率和召回率)。

    • 遗传算子(Genetic Operators) :定义算法如何演化,包括:

      • 选择(Selection) :如何从当前种群中选择优秀的个体进入下一代。
      • 交叉(Crossover) :如何组合两个父代个体以产生新的子代。
      • 变异(Mutation) :如何对个体进行小幅度的随机改变,以增加种群的多样性。
  4. 运行算法:将所有部分组合起来,运行 NSGA-II 算法进行迭代演化。

  5. 分析结果:从最终的种群中提取帕累托前沿,并进行分析和可视化。

4. 案例实战:优化随机森林的精确率和召回率

# 4.1 安装并导入 DEAP 库
# !pip install deap
import random
from deap import base, creator, tools, algorithms

# 4.2 定义问题:个体与适应度

# 定义适应度:我们需要最大化两个目标,所以权重都设为 1.0
# weights=(1.0, 1.0) 表示我们希望同时最大化这两个值
creator.create("FitnessMulti", base.Fitness, weights=(1.0, 1.0))

# 定义个体:每个个体包含一组随机森林的超参数
# 我们选择优化4个关键超参数:
# 1. n_estimators (树的数量)
# 2. max_depth (树的最大深度)
# 3. min_samples_split (节点分裂所需的最小样本数)
# 4. min_samples_leaf (叶节点所需的最小样本数)
creator.create("Individual", list, fitness=creator.FitnessMulti)

# 4.3 创建工具箱 (Toolbox)
toolbox = base.Toolbox()

# 定义每个基因(超参数)的生成方式
# n_estimators: 整数,范围 [50, 300]
toolbox.register("attr_n_estimators", random.randint, 50, 300)
# max_depth: 整数,范围 [5, 50]
toolbox.register("attr_max_depth", random.randint, 5, 50)
# min_samples_split: 整数,范围 [2, 20]
toolbox.register("attr_min_samples_split", random.randint, 2, 20)
# min_samples_leaf: 整数,范围 [1, 20]
toolbox.register("attr_min_samples_leaf", random.randint, 1, 20)

# 将基因组合成个体
# n=1 表示每个函数调用1次,生成一个完整的个体
toolbox.register("individual", tools.initCycle, creator.Individual, 
               (toolbox.attr_n_estimators, toolbox.attr_max_depth, 
                toolbox.attr_min_samples_split, toolbox.attr_min_samples_leaf), n=1)

# 定义种群
toolbox.register("population", tools.initRepeat, list, toolbox.individual)

# 4.4 定义评估函数
def evaluate_rf(individual):
    """
    评估函数:接收一个个体(超参数组合),返回其在训练集交叉验证中的(精确率, 召回率)
    """
    # 从个体中解析超参数
    n_estimators, max_depth, min_samples_split, min_samples_leaf = individual
    
    # 创建并训练随机森林模型
    rf = make_classifier(RandomForestClassifier(
        n_estimators=n_estimators,
        max_depth=max_depth,
        min_samples_split=min_samples_split,
        min_samples_leaf=min_samples_leaf,
        random_state=42,
        n_jobs=-1  # 使用所有可用的CPU核心
    ))
    scores = cross_validate(rf, X_train, y_train, cv=5,
                            scoring={'precision': 'precision', 'recall': 'recall'})
    precision = scores['test_precision'].mean()
    recall = scores['test_recall'].mean()
    return (precision, recall)

# 注册评估函数、交叉、变异和选择算子
toolbox.register("evaluate", evaluate_rf)
toolbox.register("mate", tools.cxTwoPoint) # 两点交叉
toolbox.register("mutate", tools.mutUniformInt, low=[50, 5, 2, 1], up=[300, 50, 20, 20], indpb=0.2) # 均匀整数变异
toolbox.register("select", tools.selNSGA2) # NSGA-II 选择算法

4.5 运行 NSGA-II 算法

现在,我们设置好遗传算法的参数并运行它。

  • ​NGEN:演化的代数。
  • ​MU:每一代种群的个体数量。
  • ​CXPB:交叉概率。
  • ​MUTPB:变异概率。

注意:这个过程会训练大量模型,可能需要几分钟到十几分钟的时间,具体取决于你的计算机性能。

def run_optimization():
    """
    运行NSGA-II多目标优化算法
    
    这个函数的作用:
    1. 设置统计信息收集器,用于监控优化过程
    2. 创建初始种群
    3. 运行遗传算法进行多目标优化
    4. 返回最终的优化结果
    """
    
    # 统计信息收集器 - 用于监控优化过程
    # 这些统计信息帮助我们了解算法的收敛情况
    stats = tools.Statistics(lambda ind: ind.fitness.values)  # 获取每个个体的适应度值
    
    # 注册统计函数 - 对每一代种群计算这些统计量
    stats.register("avg", np.mean, axis=0)  # 平均值:看整体水平趋势
    stats.register("std", np.std, axis=0)   # 标准差:看种群多样性
    stats.register("min", np.min, axis=0)   # 最小值:看最差个体表现  
    stats.register("max", np.max, axis=0)   # 最大值:看最好个体表现

    print("   开始多目标优化...")
    print("   将监控以下统计信息:")
    print("   • avg: 每一代的平均精确率和召回率")
    print("   • std: 种群的多样性程度") 
    print("   • min: 最差个体的表现")
    print("   • max: 最好个体的表现")
    print()

    # 初始化种群
    pop = toolbox.population(n=50)  # 创建50个随机个体作为初始种群
    print(f"初始化种群完成,种群大小: {len(pop)}")
    
    # 运行NSGA-II算法进行演化
    print("开始演化过程...")
    start_time = time.time()
    
    # eaMuPlusLambda 是 DEAP 提供的 (μ + λ) 演化策略
    # μ: 父代数量,λ: 子代数量
    final_pop, logbook = algorithms.eaMuPlusLambda(
        pop, toolbox, 
        mu=50,           # 父代种群大小
        lambda_=50,      # 每一代产生的子代数量  
        cxpb=0.7,        # 交叉概率 (70%的个体会进行交叉)
        mutpb=0.2,       # 变异概率 (20%的个体会发生变异)
        ngen=20,         # 演化代数 (为了演示设置得较小)
        stats=stats,     # 统计信息收集器
        halloffame=None, # 不使用名人堂
        verbose=True     # 显示每一代的进化过程
    )
    
    end_time = time.time()
    
    print(f"\n多目标优化完成!")
    print(f"总耗时: {end_time - start_time:.2f} 秒")
    print(f"最终种群大小: {len(final_pop)}")
    
    return final_pop

# 运行优化
print("=" * 60)
print("开始运行多目标优化算法")
print("=" * 60)
final_pop = run_optimization()

这里返回每个目标的均值、方差等

5. 结果分析与可视化

算法运行结束后,final_pop 中包含了最后一代的全部个体。由于 NSGA-II 算法的特性,这个最终种群本身就很好地代表了帕累托前沿。我们可以从中提取出非支配解,并进行可视化。

# 5.1 提取帕累托前沿的解

# 非支配排序取第一前沿;selBest 的字典序排序不能替代帕累托前沿。
pareto_front = tools.sortNondominated(final_pop, k=len(final_pop), first_front_only=True)[0] 

print(f"找到了 {len(pareto_front)} 个帕累托最优解。")
print("\n部分最优解 (超参数) 及其对应的 (精确率, 召回率):")
print("-" * 60)
print("n_est, max_d, min_split, min_leaf | Precision | Recall")
print("-" * 60)

# 存储结果用于绘图
pareto_points = []
for ind in pareto_front:
    params = ind
    fitness = ind.fitness.values
    pareto_points.append(fitness)
    print(f"{str(params):<30} | {fitness[0]:.5f}   | {fitness[1]:.5f}")

# 转换为numpy数组方便处理
pareto_points = np.array(pareto_points)

# 5.2 可视化帕累托前沿
plt.figure(figsize=(10, 7))

# 绘制基准模型
base_scores = cross_validate(rf_model, X_train, y_train, cv=5, scoring=['precision', 'recall'])
base_precision = base_scores['test_precision'].mean()
base_recall = base_scores['test_recall'].mean()
plt.scatter(base_recall, base_precision, marker='s', color='r', s=150, label=f'基准模型 (Recall={base_recall:.3f}, Precision={base_precision:.3f})', zorder=3)

# 绘制帕累托前沿
plt.scatter(pareto_points[:, 1], pareto_points[:, 0], 
            facecolors='none', edgecolors='b', s=80, label='帕累托前沿解', zorder=2)

plt.title('多目标优化结果:帕累托前沿', fontsize=16)
plt.xlabel('召回率 (Recall)', fontsize=12)
plt.ylabel('精确率 (Precision)', fontsize=12)
plt.grid(True, linestyle='--', alpha=0.6)
plt.legend()
plt.show()

结果解读

上图中的蓝色圆圈构成了我们找到的 帕累托前沿。每一个点都代表了一组“最优”的超参数组合。

  • 红色方块 是我们最开始训练的 基准模型。
  • 可以看到,所有帕累托前沿上的解都 支配 了基准模型(即在召回率和精确率上都更好或至少有一项更好)。

如何选择最终模型?

现在,你可以根据业务需求从帕累to前沿中选择一个最合适的模型:

  • 如果业务更看重“不漏掉” (例如,在癌症筛查中,宁可误报也不能漏掉真正的病人),你应该选择 召回率(Recall)更高 的点(图右侧的点)。
  • 如果业务更看重“不误报” (例如,在垃圾邮件过滤中,宁可漏掉一些垃圾邮件也不能把正常邮件当成垃圾邮件),你应该选择 精确率(Precision)更高 的点(图上方的点)。
  • 如果需要一个 平衡 的模型,可以选择位于“拐点”附近的点。

通过多目标优化,我们不再是得到一个单一的“最佳”模型,而是得到了一系列“各有所长”的优秀模型,这为实际应用提供了更大的灵活性。