本篇文章主要来实操官方文档中关于【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()
可视化分类器及数据
# 可视化分类器及数据
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()
背景颜色即为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项目。
关于这部分内容,在阅读源码时,个人认为软件框架大于技术细节,请勿过早钻入代码细节,大量阅读容易伤身。
该算法是我认为最简单的分类算法。简单来说,就是给定了训练数据后,根据标签样本进行分类,如上述iris给定了标签样本{0,1,2}后,按标签进行分组,计算每个标签组特征向量的均值,作为模型分类依据。在源码中用变量centroids_ = np.empty((n_classes,n_features),dtype = np.float64)
表示。有了标签组的均值后,拿带预测的数据与每个标签的centroids_
计算,与之距离最小的便是我们预测的分类标签。
sklearn的目录结构还是相当清楚的,主目录结构有:
所有的机器学习算法都放在了sklearn文件夹下,examples文件则是官方提供的各种测试用例,可供初学者学习。而我们着重分析的是sklearn下的neighbors部分的源码。sklearn的目录结构参考如下:
好了,直接进入主题吧,为了能够极大的简化sklearn的分析难度,我自己按照它模仿了一个自己的机器学习lib库,项目名为DML,开源在Github上,有兴趣的可以fork下。链接请点这里。
在这篇文章中,我们重点关注sklearn下的datasets,metric,neighbors,preprocessing和utils包。
从名字就可以看出,该包的主要功能就是为了加载数据,在实战时,我们用到了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 `.
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的作用:
对于.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
方法实现。
咱们再来看看学习模型nearest_centroid
,该类只有两个成员方法fit
和predict
,以及构造函数__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/ordot(y, y)
can be pre-computed.
我们简单测试下该方法,继续实战内容,我们取数据集中前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
中,但别忘了,它还继承了两个父类BaseEstimator
和ClassifierMixin
,BaseEstimator
我们暂时用不到不去分析,重点来看看ClassifierMixin
,它位于sklearn包下的base.py
文件中,类结构如下:
原来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上的一层迷雾,对整个框架也有了一些基础的认识。