sklearn 源码分析系列:neighbors(1)



by DemonSonggithub源码链接(https://github.com/demonSong/DML)


《数学之美》by 吴军


“很多具体的搜索技术很快会从独门绝技到普及,再到落伍,追求术的人一辈子工作很辛苦。只有掌握了搜索的本质和精髓才能永远游刃有余。”

Nearest Centroid Classifier

本篇文章主要来实操官方文档中关于【Nearest Neighbors】的相关知识。详见文档

这里分析采用了Ipython notebook.

加载数据

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
from sklearn import neighbors, datasets
from sklearn.neighbors.nearest_centroid import NearestCentroid

n_neighbors = 15

# 加载数据
iris = datasets.load_iris()
print(iris)

这是sklearn所提供的数据集,后文会分析它们是如何被加载的。此处,我们得到了iris的数据。

iris数据集分析

{'target': array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
       2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
       2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]), 'feature_names': ['sepal length (cm)', 'sepal width (cm)', 'petal length (cm)', 'petal width (cm)'], 'data': array([[ 5.1,  3.5,  1.4,  0.2],
       [ 4.9,  3. ,  1.4,  0.2],
       [ 4.7,  3.2,  1.3,  0.2],
       [ 4.6,  3.1,  1.5,  0.2])
}

控制台输出的部分数据,很简单,输入空间x的特征有四个维度,输出标签分别为0,1,2。

二维可视化
由于目前输入样例是四维的特征向量,这里我们挑选两个维度进行可视化。

# 二维可视化
X = iris.data[:,:2]
y = iris.target

cmap_bold = ListedColormap(['#FF0000', '#00FF00', '#0000FF'])

plt.figure()

plt.scatter(X[:, 0], X[:, 1], c=y, cmap=cmap_bold)
plt.xlim(xx.min(), xx.max())
plt.ylim(yy.min(), yy.max())
plt.title("3-Class classification")

plt.show()

sklearn 源码分析系列:neighbors(1)_目录结构

可视化分类器及数据

# 可视化分类器及数据
h = .02

cmap_light = ListedColormap(['#FFAAAA', '#AAFFAA', '#AAAAFF'])

clf = NearestCentroid()
clf.fit(X, y)

# 计算每个特征向量的最大值和最小值
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
                     np.arange(y_min, y_max, h))
Z = clf.predict(np.c_[xx.ravel(), yy.ravel()])

# 可视化分类器
Z = Z.reshape(xx.shape)
plt.figure()
plt.pcolormesh(xx, yy, Z, cmap=cmap_light)

# 可视化数据
plt.scatter(X[:, 0], X[:, 1], c=y, cmap=cmap_bold)
plt.xlim(xx.min(), xx.max())
plt.ylim(yy.min(), yy.max())
plt.title("3-Class classification")

plt.show()

sklearn 源码分析系列:neighbors(1)_ide_02

背景颜色即为NearestCentroid()分类器。从图中也可以看出,该分类器是把整个空间切分成了三个区域,达到分类的目的。

模型训练

# 模型训练
X = iris.data[:,:5]
y = iris.target

clf = NearestCentroid()
clf.fit(X,y)

score = clf.score(X,y)
print(score)

0.926666666667

该模型对iris数据集的准确率高达92.67,还是很不错的哟。详细代码可以参看github上的kaggle项目。

源码剖析

关于这部分内容,在阅读源码时,个人认为软件框架大于技术细节,请勿过早钻入代码细节,大量阅读容易伤身。

Nearest_Centroid 核心思想

该算法是我认为最简单的分类算法。简单来说,就是给定了训练数据后,根据标签样本进行分类,如上述iris给定了标签样本{0,1,2}后,按标签进行分组,计算每个标签组特征向量的均值,作为模型分类依据。在源码中用变量centroids_ = np.empty((n_classes,n_features),dtype = np.float64)表示。有了标签组的均值后,拿带预测的数据与每个标签的centroids_计算,与之距离最小的便是我们预测的分类标签。

sklearn目录结构

sklearn的目录结构还是相当清楚的,主目录结构有:

sklearn 源码分析系列:neighbors(1)_目录结构_03

所有的机器学习算法都放在了sklearn文件夹下,examples文件则是官方提供的各种测试用例,可供初学者学习。而我们着重分析的是sklearn下的neighbors部分的源码。sklearn的目录结构参考如下:

sklearn 源码分析系列:neighbors(1)_数据_04

好了,直接进入主题吧,为了能够极大的简化sklearn的分析难度,我自己按照它模仿了一个自己的机器学习lib库,项目名为DML,开源在Github上,有兴趣的可以fork下。链接请点这里

DML目录结构:

sklearn 源码分析系列:neighbors(1)_目录结构_05

在这篇文章中,我们重点关注sklearn下的datasets,metric,neighbors,preprocessing和utils包。

datasets

从名字就可以看出,该包的主要功能就是为了加载数据,在实战时,我们用到了iris = datasets.load_iris(),正是由该功能包来完成的。

数据集以.csv的格式,或者.txt的格式存放在datasets文件夹下的data文件内。在datasets包内有个base.py文件,完成数据加载工作,代码如下。

base.py

def load_iris(return_X_y=False):
    """Load and return the iris dataset (classification).

    The iris dataset is a classic and very easy multi-class classification
    dataset.

    =================   ==============
    Classes                          3
    Samples per class               50
    Samples total                  150
    Dimensionality                   4
    Features            real, positive
    =================   ==============

    Read more in the :ref:`User Guide <datasets>`.

    Parameters
    ----------
    return_X_y : boolean, default=False.
        If True, returns ``(data, target)`` instead of a Bunch object.
        See below for more information about the `data` and `target` object.

        .. versionadded:: 0.18

    Returns
    -------
    data : Bunch
        Dictionary-like object, the interesting attributes are:
        'data', the data to learn, 'target', the classification labels,
        'target_names', the meaning of the labels, 'feature_names', the
        meaning of the features, and 'DESCR', the
        full description of the dataset.

    (data, target) : tuple if ``return_X_y`` is True

        .. versionadded:: 0.18

    Examples
    --------
    Let's say you are interested in the samples 10, 25, and 50, and want to
    know their class name.

    >>> from sklearn.datasets import load_iris
    >>> data = load_iris()
    >>> data.target[[10, 25, 50]]
    array([0, 0, 1])
    >>> list(data.target_names)
    ['setosa', 'versicolor', 'virginica']
    """
    module_path = dirname(__file__)
    data, target, target_names = load_data(module_path, 'iris.csv')

    with open(join(module_path, 'descr', 'iris.rst')) as rst_file:
        fdescr = rst_file.read()

    if return_X_y:
        return data, target

    return Bunch(data=data, target=target,
                 target_names=target_names,
                 DESCR=fdescr,
                 feature_names=['sepal length (cm)', 'sepal width (cm)',
                                'petal length (cm)', 'petal width (cm)'])

数据加载细节我们就不去研究了,此处它做了一个Bunch,把读来的data数据和target数据传给了Bunch类,而Bunch来继承了dict,所以在数据读取时,我们以字典的形式进行访问。

Base中的Bunch类

class Bunch(dict):
    """Container object for datasets

    Dictionary-like object that exposes its keys as attributes.

    >>> b = Bunch(a=1, b=2)
    >>> b['b']
    2
    >>> b.b
    2
    >>> b.a = 3
    >>> b['a']
    3
    >>> b.c = 6
    >>> b['c']
    6

    """

    def __init__(self, **kwargs):
        super(Bunch, self).__init__(kwargs)

    def __setattr__(self, key, value):
        self[key] = value

    def __dir__(self):
        return self.keys()

    def __getattr__(self, key):
        try:
            return self[key]
        except KeyError:
            raise AttributeError(key)

简而言之,言而简之,我们用一个框图来描述datasets的作用:

sklearn 源码分析系列:neighbors(1)_目录结构_06

对于.csv文件的数据加载都可以由load_data来统一加载。那么它是怎么做到我想调用啥就调用啥的咧?在load_iris()方法中,有代码:
data,target,target_names = load_data(module_path,'iris.csv')

load_wine()方法中,同样有:
data,target,target_names = load_data(module_path,'wine_data.csv')

喔,原来是通过传入一个特定的fileName就可以了啊。那就剩下两个问题了,load_data()中有什么,以及module_path是什么鬼东西。

我们进入到load_data()的世界来瞧一瞧,看一看。喔,你会发现有这样一句:
with open(join(module_path, 'data', data_file_name)) as csv_file:

这里做的是字符串的拼接,只要是.csv文件,就统一按照下面的代码来操作,具体地请参看源码,我们重点关注module_path,它是数据加载的关键。

load_iris()中:module_path = dirname(__file__),那就需要详细讨论下dirname(__file__)的作用了。

__file__是用来获得模块所在的路径的,不信可以测试下,那么dirname(__file__)取的就是模块的绝对路径了。这里我们调试输出:
module_path = C:\Users\Administrator\AppData\Local\Programs\Python\Python35\lib\site-packages\sklearn\datasets

可见直接定位到了该模块所在的目录下了,那么文件读取直接在该模块下的子文件中读取即可,由join方法实现。

neighbors && metrics

咱们再来看看学习模型nearest_centroid,该类只有两个成员方法fitpredict,以及构造函数__init__,构造函数传入两个参数metric,shrink_threshold,在几何空间两点间的距离默认为欧几里德距离。

def __init__(self, metric='euclidean', shrink_threshold=None):
        self.metric = metric
        self.shrink_threshold = shrink_threshold

fit方法

def fit(self, X, y):
    .....

    # 关键方法
    self.centroids_[cur_class] = X[center_mask].mean(axis = 0)
    ......
    return self

fit方法没有什么特别的地方,只是为了求每组标签样本的均值,把它放入到centroids_中。

predict方法

def predict(self, X):
        """Perform classification on an array of test vectors X.

        The predicted class C for each sample in X is returned.

        Parameters
        ----------
        X : array-like, shape = [n_samples, n_features]

        Returns
        -------
        C : array, shape = [n_samples]

        Notes
        -----
        If the metric constructor parameter is "precomputed", X is assumed to
        be the distance matrix between the data to be predicted and
        ``self.centroids_``.
        """
        check_is_fitted(self, 'centroids_')

        X = check_array(X, accept_sparse='csr')
        return self.classes_[pairwise_distances(
            X, self.centroids_, metric=self.metric).argmin(axis=1)]

很简洁的代码,原因在于计算几何距离并不由nearest_centroid来做,而是转交给了pairwise_distance()来完成。专业的事交给专业的模块,吼吼。那么它在哪呢?
from ..metrics.pairwise import pairwise_distances
是和它平行模块metrics.pairwise中完成的,所以我们再来看看,pairwise中做了什么吧。

def pairwise_distances(X, Y=None, metric="euclidean", n_jobs=1, **kwds):
    ......
    if metric == "precomputed":
        X, _ = check_pairwise_arrays(X, Y, precomputed=True)
        return X
    # 关键部分
    elif metric in PAIRWISE_DISTANCE_FUNCTIONS:
        func = PAIRWISE_DISTANCE_FUNCTIONS[metric]
    ......

    # 责任再转移
    return _parallel_pairwise(X, Y, func, n_jobs, **kwds)

这是选择计算“公式”的地方,我注释了一个关键部分,它用到了一个map和函数指针,从map中挑选指定的方法来计算距离,然后把方法当作参数在传入到了_parallel_pairwise()方法中。

可选的计算公式有:

# Helper functions - distance
PAIRWISE_DISTANCE_FUNCTIONS = {
    # If updating this dictionary, update the doc in both distance_metrics()
    # and also in pairwise_distances()!
    'cityblock': manhattan_distances,
    'cosine': cosine_distances,
    'euclidean': euclidean_distances,
    'l2': euclidean_distances,
    'l1': manhattan_distances,
    'manhattan': manhattan_distances,
    'precomputed': None,  # HACK: precomputed is always allowed, never called
}

所以当我们传入‘euclidean’时,选择的便是euclidean_distances方法来完成距离计算。而_parallel_pairwise()是为了能够让任务进行多线程处理,而扩展的计算方法。我们就不去探讨了,我们只需要知道,调用它后,进一步的就会调用euclidean_distances()方法。

def euclidean_distances(X, Y=None, Y_norm_squared=None, squared=False,
                        X_norm_squared=None):
X, Y = check_pairwise_arrays(X, Y)

    if X_norm_squared is not None:
        XX = check_array(X_norm_squared)
        if XX.shape == (1, X.shape[0]):
            XX = XX.T
        elif XX.shape != (X.shape[0], 1):
            raise ValueError(
                "Incompatible dimensions for X and X_norm_squared")
    else:
        XX = row_norms(X, squared=True)[:, np.newaxis]

    if X is Y:  # shortcut in the common case euclidean_distances(X, X)
        YY = XX.T
    elif Y_norm_squared is not None:
        YY = np.atleast_2d(Y_norm_squared)

        if YY.shape != (1, Y.shape[0]):
            raise ValueError(
                "Incompatible dimensions for Y and Y_norm_squared")
    else:
        YY = row_norms(Y, squared=True)[np.newaxis, :]

    distances = safe_sparse_dot(X, Y.T, dense_output=True)
    distances *= -2
    distances += XX
    distances += YY
    np.maximum(distances, 0, out=distances)

    if X is Y:
        # Ensure that distances between vectors and themselves are set to 0.0.
        # This may not be the case due to floating point rounding errors.
        distances.flat[::distances.shape[0] + 1] = 0.0

    return distances if squared else np.sqrt(distances, out=distances)

来测试下这个方法吧,它的计算公式为:
dist(x,y) = sqrt(dot(x,x) - 2 * dot(x,y) + dot(y,y))
令我好奇的是为什么不直接使用dist(x,y)=(x−y)2来计算,而是展开成了x2−2xy+y2。官方解释为:

This formulation has two advantages over other ways of computing distances.First, it is computationally efficient when dealing with sparse data.Second, if one argument varies but the other remains unchanged, then dot(x, x) and/or dot(y, y) can be pre-computed.

  1. 对于稀疏度很大的数据源来说,它计算的效率更高。
  2. 如果其中某个点没有发生变化时,dot(x,x)和dot(y,y)中的一个或两个已经被计算过了,不必再重复计算了。

我们简单测试下该方法,继续实战内容,我们取数据集中前5条数据拿来计算,代码如下:

# 测试euclidean距离
from sklearn.metrics.pairwise import euclidean_distances

test = X[:5,:]

euclidean_distances(test,clf.centroids_)

Out[116]:
test:
array([[ 5.1  3.5  1.4  0.2]
 [ 4.9  3.   1.4  0.2]
 [ 4.7  3.2  1.3  0.2]
 [ 4.6  3.1  1.5  0.2]
 [ 5.   3.6  1.4  0.2]])

clf.centroids_:
array([[ 5.006  3.418  1.464  0.244]
 [ 5.936  2.77   4.26   1.326]
 [ 6.588  2.974  5.552  2.026]])

distances:
array([[ 0.14694217,  3.26791554,  4.80252017],
       [ 0.43816892,  3.25171831,  4.83977272],
       [ 0.41230086,  3.42667069,  5.00293914],
       [ 0.51883716,  3.28318017,  4.87042093],
       [ 0.19796969,  3.31850448,  4.84633882]])

是不是很给力,刚好计算得出了五个点分别对应三个标签各自的距离。

还记得刚开始的score = clf.score(X,y)方法么,我们发现它并没有出现在NearestCentroid中,但别忘了,它还继承了两个父类BaseEstimatorClassifierMixinBaseEstimator我们暂时用不到不去分析,重点来看看ClassifierMixin,它位于sklearn包下的base.py文件中,类结构如下:

sklearn 源码分析系列:neighbors(1)_目录结构_07

原来score()方法藏在了父类ClassifierMixin中啊,可为什么要这么做呢?来看看代码:

class ClassifierMixin(object):
    """Mixin class for all classifiers in scikit-learn."""
    _estimator_type = "classifier"

    def score(self, X, y, sample_weight=None):
        """Returns the mean accuracy on the given test data and labels.

        In multi-label classification, this is the subset accuracy
        which is a harsh metric since you require for each sample that
        each label set be correctly predicted.

        Parameters
        ----------
        X : array-like, shape = (n_samples, n_features)
            Test samples.

        y : array-like, shape = (n_samples) or (n_samples, n_outputs)
            True labels for X.

        sample_weight : array-like, shape = [n_samples], optional
            Sample weights.

        Returns
        -------
        score : float
            Mean accuracy of self.predict(X) wrt. y.

        """
        from .metrics import accuracy_score
        return accuracy_score(y, self.predict(X), sample_weight=sample_weight)

非常的简短,一样的道理,分数的计算完全交给了metrics包来完成,自己不做任何操作。这里有意思的是self.predict(X),它自己调用了预测方法,这是典型的多态和模版方法的综合使用,在多数分类预测的子类中,fit()和predict()方法因具体的算法而改变,但对于评分这个方法来说,每个子类的执行框架是一样的,所以完全可以把它抽象到父类去完成,让父类构建一个模版框架,由子类来实现各种特定算法。

accuracy_score()方法又回到了mertics包,它在classification.py中,代码如下:

def accuracy_score(y_true, y_pred, normalize=True, sample_weight=None):
    y_type, y_true, y_pred = _check_targets(y_true, y_pred)
    if y_type.startswith('multilabel'):
        differing_labels = count_nonzero(y_true - y_pred, axis=1)
        score = differing_labels == 0
    else:
        score = y_true == y_pred

    return _weighted_sum(score, sample_weight, normalize)

遗留一个问题,权值sample_weight将派什么用场,后续再去讨论。继续看_weighted_sum()方法。

def _weighted_sum(sample_score, sample_weight, normalize=False):
    if normalize:
        return np.average(sample_score, weights=sample_weight)
    elif sample_weight is not None:
        return np.dot(sample_score, sample_weight)
    else:
        return sample_score.sum()

对所有预测正确的值,即y_true == y_pred求和,即为我们的结果了。

到此,关于nearest_centroid的源码已经分析完毕了,它没有太多东西,但多多少少让我扒开了掩盖在sklearn上的一层迷雾,对整个框架也有了一些基础的认识。