0. 葡萄酒数据¶
先看样本、特征和类别。决策树要学习的是:哪些化学指标最能把不同葡萄酒类别分开。
In [2]:
# 加载 Wine 经典分类数据集:13 个化学特征预测 3 类葡萄酒。
wine = load_wine(as_frame=True)
tree_df = wine.frame.copy()
feature_names = wine.feature_names
feature_names_cn = [
"酒精", "苹果酸", "灰分", "灰分碱度", "镁", "总酚", "黄酮",
"非黄酮酚", "原花青素", "颜色强度", "色调", "稀释酒吸光度", "脯氨酸",
]
feature_cn_map = dict(zip(feature_names, feature_names_cn))
class_names = [f"葡萄酒第 {i} 类" for i in range(len(wine.target_names))]
tree_df["类别名称"] = tree_df["target"].map(dict(enumerate(class_names)))
X_tree = tree_df[feature_names]
y_tree = tree_df["target"]
summary_df = pd.DataFrame(
{
"样本数": [len(tree_df)],
"特征数": [len(feature_names)],
"类别数": [len(class_names)],
}
)
display(summary_df)
display(tree_df.rename(columns=feature_cn_map).head(8))
display(tree_df["类别名称"].value_counts().rename_axis("类别").reset_index(name="样本数"))
| 样本数 | 特征数 | 类别数 | |
|---|---|---|---|
| 0 | 178 | 13 | 3 |
| 酒精 | 苹果酸 | 灰分 | 灰分碱度 | 镁 | 总酚 | 黄酮 | 非黄酮酚 | 原花青素 | 颜色强度 | 色调 | 稀释酒吸光度 | 脯氨酸 | target | 类别名称 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 14.23 | 1.71 | 2.43 | 15.6 | 127.0 | 2.80 | 3.06 | 0.28 | 2.29 | 5.64 | 1.04 | 3.92 | 1065.0 | 0 | 葡萄酒第 0 类 |
| 1 | 13.20 | 1.78 | 2.14 | 11.2 | 100.0 | 2.65 | 2.76 | 0.26 | 1.28 | 4.38 | 1.05 | 3.40 | 1050.0 | 0 | 葡萄酒第 0 类 |
| 2 | 13.16 | 2.36 | 2.67 | 18.6 | 101.0 | 2.80 | 3.24 | 0.30 | 2.81 | 5.68 | 1.03 | 3.17 | 1185.0 | 0 | 葡萄酒第 0 类 |
| 3 | 14.37 | 1.95 | 2.50 | 16.8 | 113.0 | 3.85 | 3.49 | 0.24 | 2.18 | 7.80 | 0.86 | 3.45 | 1480.0 | 0 | 葡萄酒第 0 类 |
| 4 | 13.24 | 2.59 | 2.87 | 21.0 | 118.0 | 2.80 | 2.69 | 0.39 | 1.82 | 4.32 | 1.04 | 2.93 | 735.0 | 0 | 葡萄酒第 0 类 |
| 5 | 14.20 | 1.76 | 2.45 | 15.2 | 112.0 | 3.27 | 3.39 | 0.34 | 1.97 | 6.75 | 1.05 | 2.85 | 1450.0 | 0 | 葡萄酒第 0 类 |
| 6 | 14.39 | 1.87 | 2.45 | 14.6 | 96.0 | 2.50 | 2.52 | 0.30 | 1.98 | 5.25 | 1.02 | 3.58 | 1290.0 | 0 | 葡萄酒第 0 类 |
| 7 | 14.06 | 2.15 | 2.61 | 17.6 | 121.0 | 2.60 | 2.51 | 0.31 | 1.25 | 5.05 | 1.06 | 3.58 | 1295.0 | 0 | 葡萄酒第 0 类 |
| 类别 | 样本数 | |
|---|---|---|
| 0 | 葡萄酒第 1 类 | 71 |
| 1 | 葡萄酒第 0 类 | 59 |
| 2 | 葡萄酒第 2 类 | 48 |
1. 决策树分类¶
训练后先看测试集预测,再看分裂规则和特征重要性。树图不是装饰,它解释了模型为什么做出这个分类。
In [3]:
# 划分训练集和测试集,再训练决策树。
X_train, X_test, y_train, y_test = train_test_split(
X_tree,
y_tree,
test_size=0.28,
stratify=y_tree,
random_state=7,
)
tree = DecisionTreeClassifier(max_depth=4, min_samples_leaf=3, random_state=7)
tree.fit(X_train, y_train)
train_pred = tree.predict(X_train)
test_pred = tree.predict(X_test)
score_df = pd.DataFrame(
[
{"数据": "训练集", "样本数": len(y_train), "准确率": accuracy_score(y_train, train_pred)},
{"数据": "测试集", "样本数": len(y_test), "准确率": accuracy_score(y_test, test_pred)},
]
).round(3)
display(score_df)
test_result = X_test.rename(columns=feature_cn_map).copy()
test_result["真实类别"] = [class_names[i] for i in y_test]
test_result["预测类别"] = [class_names[i] for i in test_pred]
test_result["预测正确"] = test_result["真实类别"].eq(test_result["预测类别"])
display(test_result.head(12))
| 数据 | 样本数 | 准确率 | |
|---|---|---|---|
| 0 | 训练集 | 128 | 0.977 |
| 1 | 测试集 | 50 | 0.900 |
| 酒精 | 苹果酸 | 灰分 | 灰分碱度 | 镁 | 总酚 | 黄酮 | 非黄酮酚 | 原花青素 | 颜色强度 | 色调 | 稀释酒吸光度 | 脯氨酸 | 真实类别 | 预测类别 | 预测正确 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 159 | 13.48 | 1.67 | 2.64 | 22.5 | 89.0 | 2.60 | 1.10 | 0.52 | 2.29 | 11.75 | 0.57 | 1.78 | 620.0 | 葡萄酒第 2 类 | 葡萄酒第 2 类 | True |
| 89 | 12.08 | 1.33 | 2.30 | 23.6 | 70.0 | 2.20 | 1.59 | 0.42 | 1.38 | 1.74 | 1.07 | 3.21 | 625.0 | 葡萄酒第 1 类 | 葡萄酒第 1 类 | True |
| 23 | 12.85 | 1.60 | 2.52 | 17.8 | 95.0 | 2.48 | 2.37 | 0.26 | 1.46 | 3.93 | 1.09 | 3.63 | 1015.0 | 葡萄酒第 0 类 | 葡萄酒第 0 类 | True |
| 153 | 13.23 | 3.30 | 2.28 | 18.5 | 98.0 | 1.80 | 0.83 | 0.61 | 1.87 | 10.52 | 0.56 | 1.51 | 675.0 | 葡萄酒第 2 类 | 葡萄酒第 2 类 | True |
| 3 | 14.37 | 1.95 | 2.50 | 16.8 | 113.0 | 3.85 | 3.49 | 0.24 | 2.18 | 7.80 | 0.86 | 3.45 | 1480.0 | 葡萄酒第 0 类 | 葡萄酒第 0 类 | True |
| 90 | 12.08 | 1.83 | 2.32 | 18.5 | 81.0 | 1.60 | 1.50 | 0.52 | 1.64 | 2.40 | 1.08 | 2.27 | 480.0 | 葡萄酒第 1 类 | 葡萄酒第 1 类 | True |
| 142 | 13.52 | 3.17 | 2.72 | 23.5 | 97.0 | 1.55 | 0.52 | 0.50 | 0.55 | 4.35 | 0.89 | 2.06 | 520.0 | 葡萄酒第 2 类 | 葡萄酒第 2 类 | True |
| 47 | 13.90 | 1.68 | 2.12 | 16.0 | 101.0 | 3.10 | 3.39 | 0.21 | 2.14 | 6.10 | 0.91 | 3.33 | 985.0 | 葡萄酒第 0 类 | 葡萄酒第 0 类 | True |
| 135 | 12.60 | 2.46 | 2.20 | 18.5 | 94.0 | 1.62 | 0.66 | 0.63 | 0.94 | 7.10 | 0.73 | 1.58 | 695.0 | 葡萄酒第 2 类 | 葡萄酒第 2 类 | True |
| 68 | 13.34 | 0.94 | 2.36 | 17.0 | 110.0 | 2.53 | 1.30 | 0.55 | 0.42 | 3.17 | 1.02 | 1.93 | 750.0 | 葡萄酒第 1 类 | 葡萄酒第 0 类 | False |
| 80 | 12.00 | 0.92 | 2.00 | 19.0 | 86.0 | 2.42 | 2.26 | 0.30 | 1.43 | 2.50 | 1.38 | 3.12 | 278.0 | 葡萄酒第 1 类 | 葡萄酒第 1 类 | True |
| 96 | 11.81 | 2.12 | 2.74 | 21.5 | 134.0 | 1.60 | 0.99 | 0.14 | 1.56 | 2.50 | 0.95 | 2.26 | 625.0 | 葡萄酒第 1 类 | 葡萄酒第 1 类 | True |
In [4]:
# 查看分裂规则、特征重要性和混淆矩阵。
importance_df = pd.DataFrame(
{"特征": feature_names_cn, "重要性": tree.feature_importances_}
).sort_values("重要性", ascending=False)
importance_df["累计重要性"] = importance_df["重要性"].cumsum()
confusion_df = pd.DataFrame(
confusion_matrix(y_test, test_pred),
index=[f"真实_{name}" for name in class_names],
columns=[f"预测_{name}" for name in class_names],
)
print(export_text(tree, feature_names=feature_names_cn, max_depth=3))
display(importance_df.head(10).round(3))
display(confusion_df)
|--- 颜色强度 <= 3.82 | |--- 酒精 <= 13.06 | | |--- class: 1 | |--- 酒精 > 13.06 | | |--- class: 0 |--- 颜色强度 > 3.82 | |--- 黄酮 <= 1.58 | | |--- class: 2 | |--- 黄酮 > 1.58 | | |--- 脯氨酸 <= 742.00 | | | |--- 镁 <= 93.00 | | | | |--- class: 1 | | | |--- 镁 > 93.00 | | | | |--- class: 1 | | |--- 脯氨酸 > 742.00 | | | |--- class: 0
| 特征 | 重要性 | 累计重要性 | |
|---|---|---|---|
| 6 | 黄酮 | 0.430 | 0.430 |
| 9 | 颜色强度 | 0.400 | 0.829 |
| 12 | 脯氨酸 | 0.126 | 0.955 |
| 0 | 酒精 | 0.040 | 0.995 |
| 4 | 镁 | 0.005 | 1.000 |
| 1 | 苹果酸 | 0.000 | 1.000 |
| 2 | 灰分 | 0.000 | 1.000 |
| 3 | 灰分碱度 | 0.000 | 1.000 |
| 5 | 总酚 | 0.000 | 1.000 |
| 7 | 非黄酮酚 | 0.000 | 1.000 |
| 预测_葡萄酒第 0 类 | 预测_葡萄酒第 1 类 | 预测_葡萄酒第 2 类 | |
|---|---|---|---|
| 真实_葡萄酒第 0 类 | 15 | 2 | 0 |
| 真实_葡萄酒第 1 类 | 2 | 17 | 1 |
| 真实_葡萄酒第 2 类 | 0 | 0 | 13 |
In [5]:
# 绘制树结构和特征重要性条形图。
fig, ax = plt.subplots(figsize=(12.0, 6.3))
plot_tree(
tree,
feature_names=feature_names_cn,
class_names=class_names,
filled=True,
rounded=True,
impurity=False,
ax=ax,
)
ax.set_title("决策树分裂路径", loc="left", fontsize=14, fontweight="bold", color="#0f172a")
plt.tight_layout()
plt.show()
top_importance = importance_df.head(8).sort_values("重要性")
fig, ax = plt.subplots(figsize=(8.4, 5.0))
ax.barh(top_importance["特征"], top_importance["重要性"], color="#2563eb")
ax.set_title("特征重要性前 8 项", loc="left", fontsize=14, fontweight="bold", color="#0f172a")
ax.set_xlabel("重要性")
ax.grid(True, axis="x", color="#e2e8f0", linewidth=0.8)
plt.tight_layout()
plt.show()
2. 鸢尾花聚类¶
聚类没有直接使用品种标签。这里用不同 k 比较簇的形状,再用真实品种做事后对照,理解聚类是否有意义。
In [6]:
# 加载 Iris 鸢尾花数据集,用四个形态特征做聚类。
iris = load_iris(as_frame=True)
iris_df = iris.frame.copy()
iris_feature_names = iris.feature_names
iris_feature_names_cn = ["萼片长度", "萼片宽度", "花瓣长度", "花瓣宽度"]
iris_feature_cn_map = dict(zip(iris_feature_names, iris_feature_names_cn))
iris_class_names = list(iris.target_names)
iris_df["品种"] = iris_df["target"].map(dict(enumerate(iris_class_names)))
X_iris = iris_df[iris_feature_names]
scaler = StandardScaler()
X_iris_scaled = scaler.fit_transform(X_iris)
pca = PCA(n_components=2, random_state=0)
iris_pca = pca.fit_transform(X_iris_scaled)
display(iris_df.rename(columns=iris_feature_cn_map).head(8))
display(iris_df["品种"].value_counts().rename_axis("品种").reset_index(name="样本数"))
print("PCA 两个主成分解释方差占比:", np.round(pca.explained_variance_ratio_, 3))
| 萼片长度 | 萼片宽度 | 花瓣长度 | 花瓣宽度 | target | 品种 | |
|---|---|---|---|---|---|---|
| 0 | 5.1 | 3.5 | 1.4 | 0.2 | 0 | setosa |
| 1 | 4.9 | 3.0 | 1.4 | 0.2 | 0 | setosa |
| 2 | 4.7 | 3.2 | 1.3 | 0.2 | 0 | setosa |
| 3 | 4.6 | 3.1 | 1.5 | 0.2 | 0 | setosa |
| 4 | 5.0 | 3.6 | 1.4 | 0.2 | 0 | setosa |
| 5 | 5.4 | 3.9 | 1.7 | 0.4 | 0 | setosa |
| 6 | 4.6 | 3.4 | 1.4 | 0.3 | 0 | setosa |
| 7 | 5.0 | 3.4 | 1.5 | 0.2 | 0 | setosa |
| 品种 | 样本数 | |
|---|---|---|
| 0 | setosa | 50 |
| 1 | versicolor | 50 |
| 2 | virginica | 50 |
PCA 两个主成分解释方差占比: [0.73 0.229]
In [7]:
# 比较不同 k:簇内平方和用于肘部曲线,轮廓系数辅助判断聚类分离度。
k_values = range(1, 8)
kmeans_by_k = {}
labels_by_k = {}
metric_rows = []
for k in k_values:
model = KMeans(n_clusters=k, n_init=20, random_state=4)
labels = model.fit_predict(X_iris_scaled)
kmeans_by_k[k] = model
labels_by_k[k] = labels
metric_rows.append({
"k": k,
"簇内平方和": model.inertia_,
"轮廓系数": np.nan if k == 1 else silhouette_score(X_iris_scaled, labels),
"ARI(对照真实品种)": np.nan if k == 1 else adjusted_rand_score(iris.target, labels),
})
k_metrics = pd.DataFrame(metric_rows).round(3)
k_metrics_display = k_metrics.copy()
for col in ["轮廓系数", "ARI(对照真实品种)"]:
k_metrics_display[col] = k_metrics_display[col].apply(
lambda value: "未定义" if pd.isna(value) else f"{value:.3f}"
)
k_metrics_display["簇内平方和"] = k_metrics_display["簇内平方和"].map(lambda value: f"{value:.3f}")
chosen_k = 3
kmeans = kmeans_by_k[chosen_k]
final_labels = labels_by_k[chosen_k]
clustered_iris = iris_df.copy()
clustered_iris["簇"] = final_labels
cluster_profile = clustered_iris.groupby("簇")[iris_feature_names].mean().rename(columns=iris_feature_cn_map).round(2)
cluster_mix = pd.crosstab(clustered_iris["簇"], clustered_iris["品种"])
display(k_metrics_display)
display(cluster_profile)
display(cluster_mix)
| k | 簇内平方和 | 轮廓系数 | ARI(对照真实品种) | |
|---|---|---|---|---|
| 0 | 1 | 600.000 | 未定义 | 未定义 |
| 1 | 2 | 222.362 | 0.582 | 0.568 |
| 2 | 3 | 139.820 | 0.460 | 0.620 |
| 3 | 4 | 114.097 | 0.387 | 0.462 |
| 4 | 5 | 91.047 | 0.341 | 0.436 |
| 5 | 6 | 79.999 | 0.323 | 0.350 |
| 6 | 7 | 70.764 | 0.325 | 0.300 |
| 萼片长度 | 萼片宽度 | 花瓣长度 | 花瓣宽度 | |
|---|---|---|---|---|
| 簇 | ||||
| 0 | 5.80 | 2.67 | 4.37 | 1.41 |
| 1 | 5.01 | 3.43 | 1.46 | 0.25 |
| 2 | 6.78 | 3.10 | 5.51 | 1.97 |
| 品种 | setosa | versicolor | virginica |
|---|---|---|---|
| 簇 | |||
| 0 | 0 | 39 | 14 |
| 1 | 50 | 0 | 0 |
| 2 | 0 | 11 | 36 |
In [8]:
# 在 PCA 平面比较不同 k 的聚类结果。
palette = np.array(["#2563eb", "#f97316", "#16a34a", "#9333ea", "#0f766e", "#be123c", "#64748b"])
fig, axes = plt.subplots(2, 2, figsize=(10.2, 8.0), sharex=True, sharey=True)
for ax, k in zip(axes.ravel(), [2, 3, 4, 5]):
labels = labels_by_k[k]
centers_2d = pca.transform(kmeans_by_k[k].cluster_centers_)
ax.scatter(
iris_pca[:, 0],
iris_pca[:, 1],
c=palette[labels],
s=58,
alpha=0.85,
edgecolors="white",
linewidth=0.6,
)
ax.scatter(
centers_2d[:, 0],
centers_2d[:, 1],
marker="X",
s=150,
color="#0f172a",
edgecolors="white",
linewidth=0.8,
)
ax.set_title(f"k={k}", loc="left", fontweight="bold")
ax.grid(True, color="#e2e8f0", linewidth=0.8)
fig.suptitle("鸢尾花 KMeans 不同 k 的聚类效果", x=0.08, ha="left", fontsize=14, fontweight="bold", color="#0f172a")
fig.supxlabel("PCA 第一主成分")
fig.supylabel("PCA 第二主成分")
plt.tight_layout()
plt.show()
# 肘部曲线观察簇内平方和下降速度,k=3 后收益明显变小。
fig, ax1 = plt.subplots(figsize=(8.6, 5.0))
ax1.plot(k_metrics["k"], k_metrics["簇内平方和"], marker="o", linewidth=2.4, color="#2563eb", label="簇内平方和")
ax1.axvline(chosen_k, color="#f97316", linestyle="--", linewidth=1.8)
ax1.set_title("肘部曲线与 k 选择", loc="left", fontsize=14, fontweight="bold", color="#0f172a")
ax1.set_xlabel("k")
ax1.set_ylabel("簇内平方和", color="#2563eb")
ax1.tick_params(axis="y", labelcolor="#2563eb")
ax1.grid(True, color="#e2e8f0", linewidth=0.8)
ax2 = ax1.twinx()
ax2.plot(k_metrics["k"], k_metrics["轮廓系数"], marker="s", linewidth=2.0, color="#16a34a", label="轮廓系数")
ax2.set_ylabel("轮廓系数", color="#16a34a")
ax2.tick_params(axis="y", labelcolor="#16a34a")
ax1.text(chosen_k + 0.08, ax1.get_ylim()[1] * 0.82, "选择 k=3", color="#c2410c", fontweight="bold")
plt.tight_layout()
plt.show()