更新于 2026年10月15日 全文 3135 字未阅读

二分类:编程练习#

以下为本站根据 Google 机器学习速成课程源示例代码内容整理而来,同时对其中的部分内容进行了梳理和补充,完整示例代码见文末。

在本节内容中,你将完成以下任务:

  • 探索数据集,它来自两种土耳其大米图像中提取出的若干测量指标;

  • 训练一个二分类器,把米粒分到对应的两个种类;

  • 评估该模型的性能。

1. 学习目标#

完成本练习之后,你将掌握:

  • 如何训练一个二分类器;

  • 如何在不同阈值下计算二分类器的各项指标;

  • 如何比较两个不同模型的 AUC 和 ROC。

2. 数据集简介#

本实验使用的是 Cinar 与 Koklu 在 2019 年发布的 Osmancik 与 Cammeo 稻米数据集。

数据集以 CC0 协议授权(长度与面积以像素为单位)。Cinar 与 Koklu 还在他们的 数据集仓库 中公开了多类(5 种大米)、开心果、葡萄干、葡萄叶等其它数据集。

Cinar, I. and Koklu, M., (2019). “Classification of Rice Varieties Using Artificial Intelligence Methods.” International Journal of Intelligent Systems and Applications in Engineering, 7(3), 188-194.


3. 加载依赖#

# 安装依赖库

!pip install google-ml-edu==0.1.2 \
    keras~=3.8.0 \
    matplotlib~=3.10.0 \
    numpy~=2.0.0 \
    pandas~=2.2.0 \
    tensorflow~=2.18.0

print('\n\n所有依赖已成功安装。')

这部分内容如果在上一个编程练习中已经完成过,这里可以忽略。如果没有完成过上一个编程练习,可自行安装。

# 加载依赖

import keras
import ml_edu.experiment
import ml_edu.results
import numpy as np
import pandas as pd
import plotly.express as px

# 下面的几行调整后续输出的粒度。
pd.options.display.max_rows = 10
pd.options.display.float_format = "{:.1f}".format

print("已成功加载依赖。")
# 加载数据集
def load_dataset():
    rice_dataset_raw = pd.read_csv("Rice_Cammeo_Osmancik.csv")
    rice_dataset = rice_dataset_raw[[
        'Area',
        'Perimeter',
        'Major_Axis_Length',
        'Minor_Axis_Length',
        'Eccentricity',
        'Convex_Area',
        'Extent',
        'Class']]
    info = rice_dataset.describe()
    print(info)
    return rice_dataset

执行完上面的代码块之后,从数据集中挑选若干列,查看这些数值特征的描述性统计。

上述代码运行结束后将会得到类似如下结果:

               Area    Perimeter  ...   Convex_Area       Extent
count   3810.000000  3810.000000  ...   3810.000000  3810.000000
mean   12667.727559   454.239180  ...  12952.496850     0.661934
std     1732.367706    35.597081  ...   1776.972042     0.077239
min     7551.000000   359.100006  ...   7723.000000     0.497413
25%    11370.500000   426.144753  ...  11626.250000     0.598862
50%    12421.500000   448.852493  ...  12706.500000     0.645361
75%    13950.000000   483.683746  ...  14284.000000     0.726562
max    18913.000000   548.445984  ...  19099.000000     0.861050

[8 rows x 7 columns]

详见 Kaggle 的 数据集说明,特别是 Provenance 部分,它解释了每一个特征的含义以及它们的计算方式。

进一步,可以通过如下方式

# 读取数据集并输出统计信息。
rice_dataset = rice_dataset_raw[[
    'Area',
    'Perimeter',
    'Major_Axis_Length',
    'Minor_Axis_Length',
    'Eccentricity',
    'Convex_Area',
    'Extent',
    'Class']]

rice_dataset.describe()

4. 数据集探索#

4.1 任务一:描述数据#

根据上文的描述性统计,回答下列问题:

  • 米粒的最小长度和最大长度分别是多少(指长轴长度 major axis length,以像素为单位)?

  • 最小米粒和最大米粒之间的面积范围是多少?

  • 最大的那粒米,它的周长距离平均周长有多少个标准差?

运行下面代码可得到答案:

# 参考答案()

print(
    f'最短的米粒长度为 {rice_dataset.Major_Axis_Length.min():.1f}px,'
    f'最长的为 {rice_dataset.Major_Axis_Length.max():.1f}px。'
)
print(
    f'最小米粒的面积为 {rice_dataset.Area.min()}px,'
    f'最大米粒的面积为 {rice_dataset.Area.max()}px。'
)
print(
    '最大的那粒米,周长为'
    f' {rice_dataset.Perimeter.max():.1f}px,'
    f'距离均值周'
    f' ~{(rice_dataset.Perimeter.max() - rice_dataset.Perimeter.mean())/rice_dataset.Perimeter.std():.1f} 个标准差'
    f'(标准差 {rice_dataset.Perimeter.std():.1f},'
    f'均值 {rice_dataset.Perimeter.mean():.1f}px)。'
)
print(
    f'计算方式为:({rice_dataset.Perimeter.max():.1f} -'
    f' {rice_dataset.Perimeter.mean():.1f})/{rice_dataset.Perimeter.std():.1f} ='
    f' {(rice_dataset.Perimeter.max() - rice_dataset.Perimeter.mean())/rice_dataset.Perimeter.std():.1f}'
)

上述代码运行结束后的将会得到类似如下结果:

最短的米粒长度为 145.3px,最长的为 239.0px。
最小米粒的面积为 7551px,最大米粒的面积为 18913px。
最大的那粒米,周长为 548.4px,距离均值周 ~2.6 个标准差(标准差 35.6,均值 454.2px)。
计算方式为:(548.4 - 454.2)/35.6 = 2.6

4.1.1 数据探索#

把若干特征两两作图,包括 3D 图。

# 创建 5 张二维散点图,按类别着色。
def explore_the_dataset(rice_dataset):
    for x_axis_data, y_axis_data in [
        ('Area', 'Eccentricity'),
        ('Convex_Area', 'Perimeter'),
        ('Major_Axis_Length', 'Minor_Axis_Length'),
        ('Perimeter', 'Extent'),
        ('Eccentricity', 'Major_Axis_Length'),
    ]:
        px.scatter(rice_dataset, x=x_axis_data, y=y_axis_data, color='Class').show()

上述代码运行结束以后将会得到5张二维散点图,其中两张如下:

土耳其大米二维图像可视化结果
土耳其大米二维图像可视化结果

MLWM:从上面两张图来看,从分类的角度来说 Major_Axis_Length 和 Minor_Axis_Length 这两个特征维度相对来说是更有效的。

4.2 任务二:3D 可视化样本#

任意输入3个特征并作出 3D 图。

# 任意输入3个特征并运行,在 3D 中绘制三个特征

def visualize_samples_in_3D(rice_dataset):
    x_axis_data = 'Eccentricity'
    y_axis_data = 'Area'
    z_axis_data = 'Major_Axis_Length'
    px.scatter_3d(rice_dataset,
                  x=x_axis_data, y=y_axis_data,
                  z=z_axis_data, color='Class').show()

上述代码输出结果为:

土耳其大米三维图像可视化结果
# 一种可能的解
# 在 3D 中绘制长轴长度、面积与偏心率,按类别着色。
px.scatter_3d(
    rice_dataset,
    x='Eccentricity',
    y='Area',
    z='Major_Axis_Length',
    color='Class',
).show()

上述代码输出结果为:

土耳其大米三维图像可视化结果

如果只挑三个特征,长轴长度、面积和偏心率(即上面的3个特征)似乎已经包含了两类样本之间大部分的判别信息(其它组合效果可能也不错)。

在我们挑出的这三个特征所张成的平面上,类别之间存在比较清晰的边界。因此,我们会先在这三个特征上训练一个模型,然后在全部特征上训练另一个模型,最后比较两者的表现。

MLWM:当然,实际上我们也可以只选择其中两个特征来训练模型。


5. 数据归一化#

在多特征模型中,每个特征的值域应该大致相当。如果一个特征的值域是 500 到 100,000,而另一个特征的值域是 2 到 12,模型为了把这些特征有效地组合起来,必须取非常大或非常小的权重。这会让模型质量变差。为了避免这一点,多特征模型中应当对特征做归一化。

关于为什么需要进行归一化的进一步详细解释,可以参见文章「标准化(Standardization):特征预处理方法」中的内容。

归一化的方式是把每个原始值转换为它对应的 Z-score。Z-score 表示的是该值距离均值有多少个标准差。

MLWM:标准化是一种统称,常见的标准化方法包括归一化(Z-score 去均值标准化)、最小-最大值标准化等。

考虑一个均值为 60、标准差为 10 的特征:

  • 原始值 75 对应的 Z-score 为 +1.5:

      Z-score = (75 - 60) / 10 = +1.5
  • 原始值 38 对应的 Z-score 为 -2.2:

      Z-score = (38 - 60) / 10 = -2.2

现在把稻米数据集中的所有数值列转换为对应的 Z-score。

def normalization(rice_dataset):
    # 计算原始数据中每一列的 Z-score,并把它们写入一个名为 df_norm 的新 DataFrame。
    feature_mean = rice_dataset.mean(numeric_only=True)
    feature_std = rice_dataset.std(numeric_only=True)
    numerical_features = rice_dataset.select_dtypes('number').columns
    normalized_dataset = (rice_dataset[numerical_features] - feature_mean) / feature_std

    # 把 Class 列复制到新 DataFrame
    normalized_dataset['Class'] = rice_dataset['Class']

    # 观察归一化后训练集中的若干值。可以看到多数 Z-score 落在 -2 到 +2 之间。
    info = normalized_dataset.head()
    print(f"\n\n归一化后数据:\n{info}")
    return normalized_dataset

上述代码运行结束以后就会得到类似如下结果:

归一化后数据:
   Area  Perimeter  Major_Axis_Length  ...  Convex_Area  Extent   Class
0   1.5        2.0                2.3  ...          1.5    -1.2  Cammeo
1   1.1        1.1                1.0  ...          1.2    -0.6  Cammeo
2   1.1        1.3                1.5  ...          1.1     0.4  Cammeo
3   0.3        0.1                0.3  ...          0.2    -0.3  Cammeo
4   1.2        1.5                1.3  ...          1.3    -0.2  Cammeo

各位读者可以将上述结果同上面未经过归一化后的原始数据进行对比。


6. 设定随机种子#

为了让实验可复现,我们设定随机数生成器的种子。这样一来,每次运行本代码运行时,数据被打乱的顺序、随机权重初始化的值等等,都会保持一致。

MLWM:通常来说,设定随机种子的目的是为了让后来者复现作者当时的结果。

keras.utils.set_random_seed(42)

7. 标签构建与数据集划分#

为了训练模型,我们需要把 Cammeo 类别标记为 ‘1’,把 Osmancik 类别标记为 ‘0’,即构建成一个二分类数据集。

def construct_and_split_dataset(normalized_dataset):
    # 新建一个列,把 Cammeo 类别标记为 '1'、Osmancik 类别标记为 '0',
    # 然后随机抽取 10 行查看。
    normalized_dataset['Class_Bool'] = (normalized_dataset['Class'] == 'Cammeo').astype(int)
        # 若类别是 Cammeo 则返回 true,是 Osmancik 则返回 false
    print(normalized_dataset.sample(10))

上述代码输出结果为:

      Area  Perimeter  Major_Axis_Length  ...  Extent     Class  Class_Bool
1011  -0.1        0.1               -0.1  ...    -1.0    Cammeo           1
3185  -0.1       -0.5               -0.5  ...    -0.3  Osmancik           0
3698   0.1       -0.1               -0.4  ...    -0.2  Osmancik           0
897    0.2        0.5                0.8  ...     1.5    Cammeo           1
3245  -1.3       -1.3               -0.9  ...    -1.1  Osmancik           0
478    0.2        0.2                0.4  ...     1.7    Cammeo           1
1731  -0.4       -0.8               -0.8  ...    -0.4  Osmancik           0
1025   0.6        0.7                0.6  ...    -0.5    Cammeo           1
429    1.2        1.2                1.4  ...     1.8    Cammeo           1
1330  -0.4       -0.0                0.4  ...    -0.1    Cammeo           1

这里看,主要是新增了 Class_Bool 列,且均为 0 和 1 取值。

接下来随机打乱数据集,并按 80% / 10% / 10% 的比例划分为训练集、验证集和测试集。

我们会用训练集学习模型参数,用验证集评估不同的模型,最后用测试集计算在验证集上表现最好的那个模型的最终指标。

进一步,在 construct_and_split_dataset() 函数中继续追加下面的内容

def construct_and_split_dataset(normalized_dataset):
    ......
    # 在第 80% 与第 90% 分位处创建索引
    number_samples = len(normalized_dataset)
    index_80th = round(number_samples * 0.8)
    index_90th = index_80th + round(number_samples * 0.1)
    
    # 随机打乱顺序,按 0.8 / 0.1 / 0.1 的比例划分训练集、验证集和测试集
    shuffled_dataset = normalized_dataset.sample(frac=1, random_state=100)
    train_data = shuffled_dataset.iloc[0:index_80th]
    validation_data = shuffled_dataset.iloc[index_80th:index_90th]
    test_data = shuffled_dataset.iloc[index_90th:]

    # 查看最后一份划分的头五行
    test_data.head()

上述代码输出结果为:

      Area  Perimeter  Major_Axis_Length  ...  Extent     Class  Class_Bool
2405  -0.4       -0.7               -0.8  ...     0.3  Osmancik           0
3062  -0.2       -0.0                0.2  ...     1.6  Osmancik           0
1155   1.4        1.2                1.1  ...     0.7    Cammeo           1
660    1.2        1.0                0.8  ...    -0.6    Cammeo           1
2451  -1.2       -1.2               -1.4  ...     0.1  Osmancik           0

[5 rows x 9 columns]

要避免让模型在训练时直接拿到标签,这叫做标签泄漏(Label Leakage),可以把特征和标签存为独立的变量即可避免。

进一步,在 construct_and_split_dataset() 函数中继续追加下面的内容

def construct_and_split_dataset(normalized_dataset):
    ......
    label_columns = ['Class', 'Class_Bool']
    
    train_features = train_data.drop(columns=label_columns)
    train_labels = train_data['Class_Bool'].to_numpy()
    validation_features = validation_data.drop(columns=label_columns)
    validation_labels = validation_data['Class_Bool'].to_numpy()
    test_features = test_data.drop(columns=label_columns)
    test_labels = test_data['Class_Bool'].to_numpy()

8. 训练模型#

在完成数据集的构建以后,接下来便可以开始训练模型。

8.1 选择输入特征#

首先,我们用 Eccentricity、Major_Axis_Length 和 Area 三个特征训练一个模型。

# 模型训练时所基于的特征名。
input_features = [
    'Eccentricity',
    'Major_Axis_Length',
    'Area',
]

8.2 定义模型构建与训练的函数#

下面的代码块定义了两个函数:

  • create_model(inputs, learning_rate, metrics),用于定义模型结构;

  • train_model(model, dataset, epochs, label_name, batch_size, shuffle),使用输入特征与标签训练模型。

注意:create_model 通过应用 sigmoid 函数实现「逻辑回归」。

此外,我们还定义了两个辅助数据结构:ExperimentSettings 与 Experiment。借助这两个简单的类,我们可以记录下每次实验使用过的超参数以及对应的运行结果。ExperimentSettings 中存放描述一次实验的所有取值(即超参数)。随后,把一次训练运行的结果(即模型与训练过程指标)存入一个 Experiment 实例中,并把那次实验使用的 ExperimentSettings 也一并保存。

# 定义创建与训练模型的函数。

def create_model(
    settings: ml_edu.experiment.ExperimentSettings,
    metrics: list[keras.metrics.Metric],
) -> keras.Model:
  """创建并编译一个简单的分类模型。"""
  model_inputs = [
      keras.Input(name=feature, shape=(1,))
      for feature in settings.input_features
  ]
  # 用一个 Concatenate 层把不同输入拼成单个张量,作为 Dense 层的输入。
  # 例如:[input_1[0][0], input_2[0][0]]

  concatenated_inputs = keras.layers.Concatenate()(model_inputs)
  model_output = keras.layers.Dense(
      units=1, name='dense_layer', activation=keras.activations.sigmoid
  )(concatenated_inputs)
  model = keras.Model(inputs=model_inputs, outputs=model_output)
  # 调用 compile 方法,把各层组装成 Keras 可以执行的模型。
  # 注意:分类任务的损失函数与回归任务不同。
  model.compile(
      optimizer=keras.optimizers.RMSprop(
          settings.learning_rate
      ),
      loss=keras.losses.BinaryCrossentropy(),
      metrics=metrics,
  )
  return model


def train_model(
    experiment_name: str,
    model: keras.Model,
    dataset: pd.DataFrame,
    labels: np.ndarray,
    settings: ml_edu.experiment.ExperimentSettings,
) -> ml_edu.experiment.Experiment:
  """把数据集送入模型进行训练。"""

  # keras.Model.fit 的 x 参数可以是数组列表,其中每个数组对应一个特征的数据。
  features = {
      feature_name: np.array(dataset[feature_name])
      for feature_name in settings.input_features
  }

  history = model.fit(
      x=features,
      y=labels,
      batch_size=settings.batch_size,
      epochs=settings.number_epochs,
  )

  return ml_edu.experiment.Experiment(
      name=experiment_name,
      settings=settings,
      model=model,
      epochs=history.epoch,
      metrics_history=pd.DataFrame(history.history),
  )


print('已定义 create_model 与 train_model 函数。')

8.3 定义绘图函数#

下面的 matplotlib 函数可以绘制一条或多条曲线,展示若干分类指标随 epoch 的变化情况。

8.4 调用创建、训练与绘图函数#

下面的代码指定了超参数,调用前面定义的函数创建并训练模型,然后绘制结果,包括准确率、精确率与召回率。

分类阈值被设为 0.35。可以试着调整阈值、再调整学习率,看看结果如何变化。

# 先定义第一组实验设置。

def experiment1(input_features,train_features,train_labels):
    settings = ml_edu.experiment.ExperimentSettings(
        learning_rate=0.001,
        number_epochs=60,batch_size=100,
        classification_threshold=0.35,
        input_features=input_features)

    metrics = [keras.metrics.BinaryAccuracy(
                    name='accuracy', threshold=settings.classification_threshold),
               keras.metrics.Precision(
                    name='precision', thresholds=settings.classification_threshold),
               keras.metrics.Recall(
                    name='recall', thresholds=settings.classification_threshold),
               keras.metrics.AUC(num_thresholds=100, name='auc')]

    # 建立模型结构。
    model = create_model(settings, metrics)

    # 在训练集上训练模型。
    experiment = train_model('baseline', model, train_features, train_labels, settings)

    # 绘制各指标随 epoch 的变化曲线
    ml_edu.results.plot_experiment_metrics(experiment, ['accuracy', 'precision', 'recall'])
    ml_edu.results.plot_experiment_metrics(experiment, ['auc'])

AUC 是在所有可能的阈值上计算的(实际代码中是 num_thresholds=100 个阈值),而准确率、精确率和召回率都只在指定阈值下计算,因此它们在上图中分开显示。

上述代码运行结束后将会得到类似如下结果:

Epoch 1/60
31/31 ━━━━━━━━━━━━━━━━━━━━ 0s 472us/step - accuracy: 0.6903 - auc: 0.8591 - loss: 0.5052 - precision: 0.5853 - recall: 0.9565
Epoch 2/60
31/31 ━━━━━━━━━━━━━━━━━━━━ 0s 415us/step - accuracy: 0.7119 - auc: 0.8745 - loss: 0.4843 - precision: 0.6040 - recall: 0.9565
Epoch 3/60
31/31 ━━━━━━━━━━━━━━━━━━━━ 0s 406us/step - accuracy: 0.7323 - auc: 0.8866 - loss: 0.4659 - precision: 0.6222 - recall: 0.9587
Epoch 4/60
31/31 ━━━━━━━━━━━━━━━━━━━━ 0s 389us/step - accuracy: 0.7497 - auc: 0.8971 - loss: 0.4486 - precision: 0.6394 - recall: 0.9565
...
Epoch 60/60
31/31 ━━━━━━━━━━━━━━━━━━━━ 0s 387us/step - accuracy: 0.9252 - auc: 0.9793 - loss: 0.1844 - precision: 0.8858 - recall: 0.9481

可视化结果为:

土耳其大米二分类模型训练可视化结果

9. 用验证集评估模型#

模型训练结束时,你会得到一个针对练集的准确率。运行下面的块,可以查看模型在验证集上的准确率。

def compare_train_validation(val_x, val_y, experiment: ml_edu.experiment.Experiment):
    print('比较训练集与验证集上的指标:')
    validation_metrics = experiment.evaluate(val_x, val_y)
    for metric, validation_value in validation_metrics.items():
        print('------')
        print(f'训练集 {metric}: {experiment.get_final_metric_value(metric):.4f}')
        print(f'验证集 {metric}:  {validation_value:.4f}')
        # 评估验证集上的指标

上述代码运行结束以后将会得到类似如下结果:

比较训练集与验证集上的指标:
------
训练集 accuracy: 0.9252
验证集 accuracy:  0.8950
------
训练集 auc: 0.9793
验证集 auc:  0.9722
------
训练集 loss: 0.1844
验证集 loss:  0.2155
------
训练集 precision: 0.8858
验证集 precision:  0.8449
------
训练集 recall: 0.9481
验证集 recall:  0.9349

看起来,这个在训练集上达到约 92% 准确率的模型,在验证集上仍有大约 90% 的准确率。能不能做得更好?我们训练一个使用全部 7 个特征的模型,并比较两者的 AUC。

列出模型训练时所使用的特征:

# 列出全部特征。
all_input_features = [
  'Eccentricity',
  'Major_Axis_Length',
  'Minor_Axis_Length',
  'Area',
  'Convex_Area',
  'Perimeter',
  'Extent',
]

9.1 使用所有特征训练并评估#

def experiment2(input_features, train_features, train_labels):
    settings_all_features = ml_edu.experiment.ExperimentSettings(
        learning_rate=0.001,
        number_epochs=60,
        batch_size=100,
        classification_threshold=0.5,
        input_features=input_features,
    )

    # 修改下面 METRICS 的定义,使得除了准确率与精确率之外,还计算召回率:
    metrics = [keras.metrics.BinaryAccuracy(name='accuracy',
                    threshold=settings_all_features.classification_threshold),
               keras.metrics.Precision(
                    name='precision',
                    thresholds=settings_all_features.classification_threshold),
               keras.metrics.Recall(
                    name='recall', thresholds=settings_all_features.classification_threshold),
               keras.metrics.AUC(num_thresholds=100, name='auc')]

    # 建立模型结构。
    model_all_features = create_model(settings_all_features, metrics)

    # 在训练集上训练模型。
    experiment_all_features = train_model('all features', model_all_features,
        train_features, train_labels, settings_all_features)

    # 绘制各指标随 epoch 的变化曲线
    ml_edu.results.plot_experiment_metrics(
        experiment_all_features, ['accuracy', 'precision', 'recall'])
    ml_edu.results.plot_experiment_metrics(experiment_all_features, ['auc'])

上述代码运行结束后将会得到类似如下结果:

Epoch 1/60
31/31 ━━━━━━━━━━━━━━━━━━━━ 0s 530us/step - accuracy: 0.7703 - auc: 0.8551 - loss: 0.4936 - precision: 0.7099 - recall: 0.7869
Epoch 2/60
31/31 ━━━━━━━━━━━━━━━━━━━━ 0s 452us/step - accuracy: 0.8087 - auc: 0.8903 - loss: 0.4500 - precision: 0.7473 - recall: 0.8380
Epoch 3/60
31/31 ━━━━━━━━━━━━━━━━━━━━ 0s 478us/step - accuracy: 0.8330 - auc: 0.9147 - loss: 0.4137 - precision: 0.7762 - recall: 0.8587
...
Epoch 59/60
31/31 ━━━━━━━━━━━━━━━━━━━━ 0s 466us/step - accuracy: 0.9295 - auc: 0.9794 - loss: 0.1797 - precision: 0.9169 - recall: 0.9190
Epoch 60/60
31/31 ━━━━━━━━━━━━━━━━━━━━ 0s 470us/step - accuracy: 0.9295 - auc: 0.9794 - loss: 0.1797 - precision: 0.9169 - recall: 0.9190

可视化结果为:

土耳其大米二分类模型训练可视化结果

9.2 在验证集上评估模型#

validation_metrics_all_features = experiment_all_features.evaluate(
    validation_features,
    validation_labels,
)
compare_train_validation(experiment_all_features, validation_metrics_all_features)

上述代码运行结束以后将会得到类似如下结果:

比较训练集与验证集上的指标:
------
训练集 accuracy: 0.9295
验证集 accuracy:  0.9160
------
训练集 auc: 0.9794
验证集 auc:  0.9719
------
训练集 loss: 0.1797
验证集 loss:  0.2156
------
训练集 precision: 0.9169
验证集 precision:  0.9006
------
训练集 recall: 0.9190
验证集 recall:  0.9112

从上述结果可以看出,第2个模型的训练集指标与验证集指标更接近,说明它对训练数据的过拟合程度更低。


10. 比较两个模型#

进一步,我们可以借助搭建实验框架(里面记录下每次实验的内容和结果),来方便地在训练过程中、或在验证集评估时对比两个或更多模型。

ml_edu.results.compare_experiment([experiment, experiment_all_features],
                                  ['accuracy', 'auc'],
                                  validation_features, validation_labels)

上述代码输出结果为:

比较训练集与验证集上的指标:
------
训练集 accuracy: 0.9304
验证集 accuracy:  0.9108
------
训练集 auc: 0.9793
验证集 auc:  0.9713
------
训练集 loss: 0.1790
验证集 loss:  0.2155
------
训练集 precision: 0.9171
验证集 precision:  0.8902
------
训练集 recall: 0.9213
验证集 recall:  0.9112

对比两个模型,两者的 AUC 都大约在 0.97–0.98 之间。看起来,加入另外4个特征并没有带来明显的模型质量提升。这也在意料之中,因为很多特征(例如面积、周长与凸包面积)之间本来就是相关的。


10.1 计算最终测试指标#

为了评估我们的模型在新数据上的表现(泛化能力),可以对表现最好的模型计算它在测试集上的评估结果。这一步只有在所有实验结束、并且已经选定要使用的模型之后才能进行。任何模型之间的比较都必须使用验证集,以避免在无意中挑出对测试集"量身定制"的模型。

MLWM:通常来说数据集一般会划分成训练集和验证集,只有在要求严格的情况下可以划分成训练集、验证集和测试集。相关文章介绍可以参见「偏差和方差:交叉验证、过拟合与模型泛化」中第4.5.4 节内容。

当然,这一步同时也是发现潜在过拟合的机会:如果模型在验证集与测试集上的评估结果差距很大,那可能是因为基于验证集的选择过程挑出的模型泛化能力较差,也有可能是因为验证集不能很好地代表整体数据分布。此时最好的做法是重新打乱数据、重新分配训练集 / 验证集 / 测试集,然后再跑一遍实验。

MLWM:模型的泛化能力差通常来说分为两种情况:糟糕的测试集导致糟糕的泛化误差和糟糕的训练集导致糟糕的泛化误差。关于这部分内容可以参见「L1与L2正则化原理:权重衰减与过拟合抑制」中的内容。

def compare_train_test(test_x, test_y,
                       experiment: ml_edu.experiment.Experiment):
    test_metrics_all_features = experiment.evaluate(test_x, test_y)
    for metric, test_value in test_metrics_all_features.items():
        print(f'测试集 {metric}:  {test_value:.4f}')

上述代码运行结束后的输出结果为:

测试集 accuracy:  0.9265
测试集 auc:  0.9766
测试集 loss:  0.1902
测试集 precision:  0.9133
测试集 recall:  0.9013

在这种情况下,我们看到测试集准确率约为 92%,与上文得到的验证集准确率很接近。这意味着我们的模型在未见过的全新数据上也应当有同等表现!

以上所有结果,按以下顺序运行即可,完整示例代码见

if __name__ == '__main__':
    rice_dataset = load_dataset()
    # 数据集探索
    statistics(rice_dataset)
    explore_the_dataset(rice_dataset)
    visualize_samples_in_3D(rice_dataset)

    # 数据集构建
    normalized_dataset = normalization(rice_dataset)
    train_x, train_y, val_x, val_y, test_x, test_y = construct_and_split_dataset(normalized_dataset)

    # 模型训练(实验1),以下3行代码运行时需同时取消注释
    input_features = ['Eccentricity', 'Major_Axis_Length', 'Area']
    e1 = experiment1(input_features, train_x, train_y)
    compare_train_validation(val_x, val_y, e1)

    # 全部特征训练模型(实验2)
    all_input_features = ['Eccentricity', 'Major_Axis_Length', 'Minor_Axis_Length',
                          'Area', 'Convex_Area', 'Perimeter', 'Extent']
    e2 = experiment2(all_input_features, train_x, train_y)
    compare_train_validation(val_x, val_y, e2)
    compare_two_model(val_x, val_y, e1, e2)

    # 测试集上验证
    compare_train_test(test_x, test_y,e2)
阅读 --