B 699个名字(机器学习领域的"Hello World")

699个名字(机器学习领域的"Hello World")

Jan 21, 2019

打开这个数据集的时候,脑子里突然闪过一个问题:class 那一列里的 2 和 4,分别对应的是"良性"和"恶性"——但这两个数字背后,坐着的是谁?

699个女人

威斯康星乳腺癌数据集(Wisconsin Breast Cancer Dataset),算是机器学习领域的"Hello World"。699个样本,每个样本有10个细胞特征:clump_thickness(结块厚度)、uniform_cell_size(细胞大小均匀性)、uniform_cell_shape(细胞形状均匀性)、marginal_adhesion(边缘黏附力)、single_epithelial_size(单上皮细胞大小)、bare_nuclei(裸核)、bland_chromatin(淡染色质)、normal_nucleoli(正常核仁)、mitoses(核分裂)。

这些特征听起来抽象,却是病理医生在显微镜下实实在在看过的东西。

他们在数细胞层数,量细胞直径,看细胞边缘是光滑还是锯齿状,观察细胞核被染成什么颜色。每一个1到10的数字,都是人眼与人脑的判断——是经验的积累,是训练的痕迹。

数据里的每一行,曾经是一个女人坐在诊室里,等待活检结果。

细胞的语言

病理医生看细胞,和我们看人很像。

看形状——圆润的倾向于良性,畸形的倾向于恶性。看边界——边界清晰的通常良性,黏连在一起的往往恶性。看颜色——染色均匀的相对正常,染色质浓集发黑的令人担忧。

这不是玄学。是几千例病例训练出来的直觉。

而机器学习做的事情,简单来说就是:绕过医生的经验,用算法把这些"直觉"数字化、规则化。SVM(Support Vector Machine,支持向量机)在高维空间里找到一条最优分割线,把良性样本和恶性样本分开。KNN(K-Nearest Neighbors,K近邻)则是"物以类聚"——看一个未知样本的5个最近邻居是什么类别,由多数投票决定它属于哪一类。

KNN 选 K=5,是因为经验发现这个数值通常效果较好。太少容易受噪声影响,太多又会模糊边界。

SVM 的核函数(kernel)把细胞特征映射到高维空间,在那个空间里,线性不可分的数据变得可以分开。

10折交叉验证

直接用全部数据训练,然后测试,会发生什么?

模型可能"背住"了训练数据,而不是真的学会了规律。就像考试前背答案,遇到新题就傻眼。

10折交叉验证(10-fold cross validation)解决的是这个问题。把数据随机分成10份,轮流让9份当训练集、1份当测试集,做10次实验,最后取平均准确率。

结果是:KNN 96.6%,SVM 96.0%。看起来差不多,但实际上——

KNN 的标准差是 2.9%,SVM 是 3.3%。KNN 更稳定。

这意味着什么?KNN 在10次实验里波动较小,SVM 则有时表现更好、有时更差。对于实际部署来说,稳定比偶尔的高分更重要。

Precision、Recall、与生命的重量

但准确率(accuracy)不是唯一的指标。在医疗场景下,误诊和漏诊的代价是不同的。

Precision(精确率):预测为恶性的样本里,真正是恶性的比例。

Recall(召回率):所有恶性样本里,被正确识别出来的比例。

F1-Score是这两者的调和平均。

SVM 报告里的 Class 2(良性):

  • Precision = 1.00:预测良性的,几乎全对
  • Recall = 0.95:100个良性里,有5个被误判为恶性

SVM 报告里的 Class 4(恶性):

  • Precision = 0.90:预测恶性的,10个里有1个其实是良性
  • Recall = 1.00:所有恶性都被找到了

对于癌症筛查,Recall 更关键。漏诊一个癌症患者,比误诊一个良性肿瘤更危险——后者只需要进一步检查,前者可能延误治疗时机。

3%的准确率误差,在699个样本里意味着约21个人。21个女人,坐在诊室里,拿到了一张写有"建议进一步检查"的报告单。

技术可以给出概率,但无法替她们承担等待的重量。

算法从未见过真正的细胞。它只是在699个女人的经验里,找到了一个参考点。


附录

数据预处理

import numpy as np
import pandas as pd
from sklearn import preprocessing, model_selection
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.metrics import classification_report, accuracy_score

# 载入数据集
names = ['id', 'clump_thickness', 'uniform_cell_size', 'uniform_cell_shape',
       'marginal_adhesion', 'single_epithelial_size', 'bare_nuclei',
       'bland_chromatin', 'normal_nucleoli', 'mitoses', 'class']
df = pd.read_csv('data.csv', names=names)

# 替换缺失数据
df.replace('?', -99999, inplace=True)
# 删除无关特征
df.drop(['id'], axis=1, inplace=True)

数据划分

X = np.array(df.drop(['class'], 1))
y = np.array(df['class'])

X_train, X_test, y_train, y_test = model_selection.train_test_split(
    X, y, test_size=0.2)

模型训练与交叉验证

seed = 8
scoring = 'accuracy'

models = []
models.append(('KNN', KNeighborsClassifier(n_neighbors = 5)))

# scikit-learn 在 0.22 版本中对 SVC 的默认参数进行了更改
# gamma 参数的默认值从 'auto' 改为 'scale'
# models.append(('SVM', SVC()))
models.append(('SVM', SVC(gamma='auto')))

results = []
names = []

for name, model in models:
    # kfold = model_selection.KFold(n_splits=10, random_state = seed)
    # 指定 seed 需要令参数 shuffle = True
    kfold = model_selection.KFold(n_splits=10, shuffle = True, random_state = seed)
    cv_results = model_selection.cross_val_score(model, X_train, y_train, cv=kfold, scoring=scoring)
    results.append(cv_results)
    names.append(name)
    msg = "%s: %f (%f)" % (name, cv_results.mean(), cv_results.std())
    print(msg)

输出:

KNN: 0.966039 (0.029270)
SVM: 0.960649 (0.032726)

模型预测与评估

for name, model in models:
    model.fit(X_train, y_train)
    predictions = model.predict(X_test)
    print(name)
    print(accuracy_score(y_test, predictions))
    print(classification_report(y_test, predictions))

SVM 输出:

0.9642857142857143
              precision    recall  f1-score   support

           2       1.00      0.95      0.97        95
           4       0.90      1.00      0.95        45

    accuracy                           0.96       140
   macro avg       0.95      0.97      0.96       140
weighted avg       0.97      0.96      0.96       140
TouchingFish.top