我们知道,机器学习本质上是一类优化问题
——获取数据样本和目标函数,并尝试优化目标函数。在监督学习中,目标函数基于数据数据样本的标签,我们的目标是最小化模型预测和实际标签之间的差异,但在无监督学习中,我们并没有数据样本的标签。
本文主要介绍无监督学习中最重要的分枝之一——聚类 (Clustering
)。 当我们需要为在不带有标签的数据中挖掘所需的潜在信息时,聚类通常是我们的首选算法。例如,在电子商务网站中,营销团队可能会需要将用户存储在不同组中,以便可以为每个用户组发送不同的定制化消息,而通常这些数以百万级的用户并没有明确的标签,此时聚类就是将这些用户归为不同用户组的唯一方法;同样,在当处理大量文档、视频或网页时,聚类通常也是一种有效的方法。
聚类算法本质上是将数据样本放入不同簇中 (clusters
),其目标是使簇内距离最小化,而使簇间距离最大化。换句话说,我们希望同一簇中的样本尽可能相似,而使不同簇中的样本差异尽可能大。
我们首先考虑最极端的情况,如果我们将每个样本视为一个簇,则簇内距离全为零,而簇间距离最大,但这显然不是我们所需要的聚类算法。因此,为了避免这种解决方案,我们通常在优化函数中添加一个约束。例如,我们可以预先定义所需的簇数量,或者设置每个簇中最小的样本数。
由于数据样本不具标签,因此也决定了聚类算法的具有多种评估指标。测量簇内距离的一种方法是计算簇中每个点与簇质心之间的距离,我们可以把质心理解为簇中所有样本的平均值,也可以称为标准差,我们也可以使用相同的距离度量函数来测量不同簇的质心间的差异。
本文中,我们将介绍 3
种常用的聚类算法,以及用于评估聚类算法性能的方法。
我们已经知道可以通过指定所需的簇数量对目标函数施加的约束,K-means
中的 K
就是簇的数量,means
就意味着簇的质心,该算法的原理如下:
- 选择
K
个随机点并将它们设置为簇质心 - 将每个数据样本分配到离它最近的质心,形成
K
个簇。 - 为新形成的簇计算新的质心。
- 由于质心已经更新,跳转到第
2
步,根据更新的质心将样本重新分配到新的簇中。如果质心没有太大移动,意味着算法已经收敛,则算法停止。
可以看出,K-Means
是一个迭代算法,它不断迭代直到收敛,同时我们也可以通过设置最大迭代次数 max_iter
超参数来防止算法无限循环。此外,我们也可以通过将 tol
超参数设置为更大的值来限制质心移动的上限,并提前停止算法的运行。有多种可用于初始化簇质心的方法,通过将算法的 init
超参数设置为 k-means++
可以确保初始质心彼此相距较远。这通常比随机初始化效果更好,K
的选择是通过 n_clusters
超参数设定。
为了演示 K-Means
算法及不同超参数的作用,我们首先创建一个简单的示例数据集。
使用 make_blobs
函数可以创建一个 blob
数据集,我们将样本数设置为 100
,并将它们分成四个簇。每个数据点只有两个特征,便于对其进行可视化,使用 cluster_std
参数来确保每个簇具有不同的标准差,也就是说不同簇的聚集程度不同。该函数还会返回数据样本的标签,可以将其用于验证聚类算法。最后,将生成的数据 x
和 y
放入 DataFrame
中:
from sklearn.datasets import make_blobs
import pandas as pd
x, y = make_blobs(n_samples=100, centers=4, n_features=2, cluster_std=[1, 1.5, 2, 2])
df_blobs = pd.DataFrame(
{
'x1': x[:,0],
'x2': x[:,1],
'y': y
}
)
现在我们已经创建了相关数据,接下来,我们创建可视化函数来直观的查看这些数据。
3.3 数据集可视化plot_2d_clusters
函数将数据 x
和标签 y
绘制到散点图中。我们为图指定一个标题,并使用 y
作为簇标签,标记每个数据样本:
def plot_2d_clusters(x, y, ax):
y_uniques = pd.Series(y).unique()
for y_unique_item in y_uniques:
x[
y == y_unique_item
].plot(title=f'{len(y_uniques)} Clusters',
kind='scatter',
x='x1', y='x2',
marker=f'${y_unique_item}$',
ax=ax,
)
我们可以使用 plot_2d_clusters()
函数可视化数据样本,如下所示:
from matplotlib import pyplot as plt
fig, ax = plt.subplots(1, 1, figsize=(10, 6))
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
plot_2d_clusters(x, y, ax)
plt.show()
如上图所示,每个数据样本都根据其给定的标签进行标记。 接下来,我们使用 K-means
算法来查看能否将数据样本正确聚类,需要注意的是,在算法训练过程中我们并不会使用这些标签。
在没有任何标签的情况下,我们如何判断 K
值大小,即 n_clusters
超参数?答案是,我们并不能准确判断。我们首先使用随机数字作为 K
值,之后,我们将学习如何为 n_clusters
找到最佳值。让首先将其设置为 5
,其他超参数保持在默认值,并训练算法:
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=5)
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
y_pred = kmeans.fit_predict(x)
现在我们已经预测了新标签,我们可以使用 plot_2d_clusters()
函数将我们的预测与原始标签进行比较:
fig, axs = plt.subplots(1, 2, figsize=(14, 6))
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
plot_2d_clusters(x, y, axs[0])
plot_2d_clusters(x, y_pred, axs[1])
axs[0].set_title(f'Actuals: {axs[0].get_title()}')
axs[1].set_title(f'KMeans: {axs[1].get_title()}')
plt.show()
生成的簇显示如下:
将 K
设置为 5
后,其中有一个簇被分成两个。簇的标签数值是任意的,算法将标签为 2
的原始簇标记为 3
,但这些值并没有任何意义,只要簇中具有相同的成员就足以表明算法成功运行。在评估聚类算法时,我们也并不会考虑这这些标签值。
但我们如何确定 K
的值?目前,只能使用不同数量的簇多次运行算法并选择效果最好的一个。接下来,我们我们循环 3
个不同的 n_clusters
值。我们还可以获取最终的质心,这些质心是在算法收敛后计算得到的,使用这些质心可以了解算法如何将每个数据点分配到相应簇中,在代码的最后,我们使用三角形标记簇的质心:
n_clusters_options = [4, 5, 6]
fig, axs = plt.subplots(1, len(n_clusters_options), figsize=(16, 6))
for i, n_clusters in enumerate(n_clusters_options):
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
kmeans = KMeans(n_clusters=n_clusters)
y_pred = kmeans.fit_predict(x)
plot_2d_clusters(x, y_pred, axs[i])
axs[i].plot(
kmeans.cluster_centers_[:,0], kmeans.cluster_centers_[:,1], 'k^', ms=12, alpha=0.75
)
plt.show()
以下是使用不同 K
值得到的 K-means
聚类效果:
通过上图可以看出,选择 4
个簇是合适的选择,但需要注意的是,我们可以直观的查看 2D
数据点,但如果我们的数据样本包含两个以上的特征,那么可视化就会变得及其困难。因此,在下一节中,我们将介绍轮廓系数来选择最佳数量的聚类簇,而无需可视化聚类效果后再进行选择。
轮廓系数 (silhouette score
) 是衡量一个样本与其自己簇中的样本和其他簇中的样本相比有多相似的度量。对于每个样本,我们首先计算该样本与同一簇中所有其他样本之间的平均距离,称这个平均距离为 A
。然后,计算同一样本与最近簇中所有其他样本之间的平均距离,将此平均距离称为 B
。那么,轮廓系数的计算公式可以定义如下:
s
i
l
h
o
u
e
t
t
e
=
B
−
A
m
a
x
(
A
,
B
)
silhouette=\frac {B-A} {max(A,B)}
silhouette=max(A,B)B−A
我们可以循环遍历多个 n_clusters
值,并在每次迭代后存储轮廓系数用于查找最合适的聚类簇数量 K
,silhouette_score
有两个参数,分别为数据点 (x
) 和预测的聚类标签 (y_pred
):
from sklearn.metrics import silhouette_score
n_clusters_options = [2, 3, 4, 5, 6, 7, 8]
silhouette_scores = []
for i, n_clusters in enumerate(n_clusters_options):
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
kmeans = KMeans(n_clusters=n_clusters)
y_pred = kmeans.fit_predict(x)
silhouette_scores.append(silhouette_score(x, y_pred))
然后,我们可以选择轮廓系数最高的 n_clusters
值,我们将计算出的轮廓系数放入 DataFrame
中,并使用条形图进行比较:
fig, ax = plt.subplots(1, 1, figsize=(12, 6), sharey=False)
pd.DataFrame(
{
'n_clusters': n_clusters_options,
'silhouette_score': silhouette_scores,
}
).set_index('n_clusters').plot(
title='KMeans: Silhouette Score vs # Clusters chosen',
kind='bar',
ax=ax
)
plt.show()
得到的结果如下图所示,证实了 4
个是簇数是最佳选择:
除了选择簇的数量外,算法的初始质心选择也会影响其准确性。错误的初始选择可能会导致 K-means
算法收敛于局部最小值。在下一节中,我们将研究初始质心对算法性能的影响。
默认情况下,Scikit-learn
中实现的 K-means
算法会选择彼此相距较远的随机初始质心,它还尝试使用多个初始质心并选择能提供最佳结果的质心。我们也可以手动设置初始质心,接下里,我们将比较两个不同的初始质心设置以查看它们对最终结果的影响:
import numpy as np
initial_centroid_options = np.array([
[(-10,5), (0, 5), (10, 0), (-10, 0)],
[(0,0), (0.1, 0.1), (0, 0), (0.1, 0.1)],
])
fig, axs = plt.subplots(1, 2, figsize=(16, 6))
for i, initial_centroids in enumerate(initial_centroid_options):
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
kmeans = KMeans(
init=initial_centroids, max_iter=500, n_clusters=4
)
y_pred = kmeans.fit_predict(x)
plot_2d_clusters(x, y_pred, axs[i])
axs[i].plot(
kmeans.cluster_centers_[:,0], kmeans.cluster_centers_[:,1], 'k^'
)
plt.show()
下图显示了算法收敛后的结果簇:
显然,第一个初始化的质心能够获得更好的聚类效果,而第二个初始质心产生的聚类效果较差,因此,我们必须考虑算法的初始化,因为它的结果是不确定。
与 K-means
算法相比,层次聚类是一种结果确定性的算法,它不依赖任何初始选择,我们将下一节介绍层次聚类。
在 K-means
聚类算法中,我们从一开始就需要指定 K
个簇,在每次迭代中,一些样本可能会改变他们的所属的簇,一些簇也可能会改变它们的质心,但最终,簇的数量是从一开始就定义好的。
而在层次聚类 (agglomerative clustering
) 中,一开始并不指定簇数量,一开始,每个样本都属于自己的簇。也就是说,开始时有多少数据样本就有多少个簇。然后,我们找到两个最接近的样本并将它们聚合到一个簇中。之后,我们通过组合下一个最近的两个样本、两个簇或一个样本和一个簇来继续迭代。每次迭代后,簇的数量都会递减一个,直到我们将所有的样本都加入一个簇中,将所有样本放在一个簇中并非我们的目标。因此,我们可以选择在任何迭代中停止算法,具体取决于我们需要的最终簇数量。
了解了层次聚类的原理后,我们学习如何使用层次聚类算法。要让聚类算法终止于聚集任务,我们需要通过 n_clusters
超参数设定的最终簇数量,我们还要研究如何计算簇间的距离。我们首先将簇数量设置为 4
,使用层次聚类算法:
from sklearn.cluster import AgglomerativeClustering
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
agglo = AgglomerativeClustering(n_clusters=4)
y_pred = agglo.fit_predict(x)
由于我们将簇数设置为 4
,因此预测的 y_pred
具有 4
个不同的值。但实质上,层次聚类算法并没有在簇数为 4
时停止,它继续聚合簇,并使用内部树结构跟踪哪些簇是属于更大簇的成员。当我们指定 4
个簇时,它会重新访问这个内部树并相应地推断簇标签。在下一节中,我们将学习如何访问算法的内部层次结构并跟踪层次聚类算法所构建的树。
我们已经知道,每个样本或簇是另一个簇的成员,而另一个簇又是更大簇的成员,依此类推。此层次结构存储在算法的 children_
属性中,此属性保存为列表形式。列表的成员数为数据样本数量减 1
,每个列表成员由两个数字组成。我们可以列出 children_
属性的最后五个成员,如下所示:
print(agglo.children_[-5:])
打印出的列表最后五个成员如下:
[[186 190]
[193 194]
[184 195]
[191 196]
[192 197]]
列表的最后一个元素是树的根,它有两个孩子,192
和 197
,这些数字是这个根节点的孩子的 ID
。大于或等于数据样本数的 ID
为聚类 ID
,而小于样本数的 ID
指的是单个样本,簇 ID
中减去数据样本的数量可以得到子列表的位置,在子列表中可以获取该簇的成员。接下来,编写递归函数 get_children
,该函数接受子节点列表和数据样本数,并返回所有簇及其成员的嵌套树,如下所示:
def get_children(node, n_samples):
if node[0] >= n_samples:
child_cluster_id = node[0] - n_samples
left = get_children(
agglo.children_[child_cluster_id],
n_samples
)
else:
left = node[0]
if node[1] >= n_samples:
child_cluster_id = node[1] - n_samples
right = get_children(
agglo.children_[child_cluster_id],
n_samples
)
else:
right = node[1]
return [left, right]
调用 get_children
函数:
root = agglo.children_[-1]
n_samples = df_blobs.shape[0]
tree = get_children(root, n_samples)
此时,tree[0]
和 tree[1]
包含树左侧和右侧样本的 ID
,它们是两个最大簇的成员。如果我们的目标是将样本分成四个簇,我们可以使用 tree[0][0]
、tree[0][1]
、tree[1][0]
和 tree[1][1]
获取,以 tree[0][0]
为例,输出其值如下:
[[3, 37], [35, [20, 46]]]
这种嵌套方式可以获取我们所需要的簇数,并相应地检索簇中的成员。我们也可以使用以下代码展平列表,以便于查看:
def flatten(sub_tree, flat_list):
if type(sub_tree) is not list:
flat_list.append(sub_tree)
else:
r, l = sub_tree
flatten(r, flat_list)
flatten(l, flat_list)
调用 flatten
函数,可以获得 tree[0][0]
的成员,如下所示:
flat_list = []
flatten(tree[0][0], flat_list)
print(flat_list)
# 输出
# [3, 37, 35, 20, 46]
接下来,我们仿照 fit_predict
的输出构建我们自己的预测标签。我们将所构建的树的不同分支成员分配不同标签,我们将预测标签命名为 y_pred_dash
:
n_samples = x.shape[0]
y_pred_dash = np.zeros(n_samples)
for i, j, label in [(0,0,0), (0,1,1), (1,0,2), (1,1,3)]:
flat_list = []
flatten(tree[i][j], flat_list)
for sample_index in flat_list:
y_pred_dash[sample_index] = label
需要注意的是,y_pred_dash
中的值应与上一节中 y_pred
中的值匹配。但由于,我们对标签的选择是任意的,因此,考虑到标签名称可能不同,我们需要更完善的评价标准来比较两个预测。下一节中,我们将介绍调整兰德系数评价聚类性能。
调整兰德系数 (adjusted Rand index
) 在分类方面与准确率类似。它计算两个标签列表之间的一致性水平,但它解决了准确率的缺陷:
- 调整兰德系数并不关心实际的标签,只要需要两个簇中的成员相同
- 与分类不同,我们最终可能会拥有较多簇,在将每个样本作为一个簇的极端情况下,如果我们忽略标签的名称,任何两个簇列表都会彼此一致。因此,调整兰特系数还需要降低了两个簇偶然达成一致的可能性。
在 Scikit-learn
中,我们可以使用 adjusted_rand_score
调用调整兰德系数比较 y_pred
和 y_pred_dash
。调整兰德系数是对称的,所以在调用此函数时,参数顺序并不重要:
from sklearn.metrics import adjusted_rand_score
print(adjusted_rand_score(y_pred, y_pred_dash))
每次迭代中,该算法结合了两个最接近的簇,很容易计算两个样本之间的距离,我们已经使用了不同的距离度量,例如欧几里得距离和曼哈顿距离。然而,簇并不是一个点,我们又该如何测量距离?是使用簇的质心还是使用在每个簇中选择一个特定的数据点来计算距离?这些选择都可以使用 linkage
超参数来指定。
默认情况下,使用欧几里得距离衡量哪些簇对之间彼此最接近,可以使用 affinity
超参数更改此默认指标,我们可以使用不同的距离度量指标,如余弦距离或曼哈顿距离。在计算两个簇之间的距离时,由于一个簇中通常包含多个数据样本,linkage
参数决定了如何测量距离。参数 linkage
值为 complete
时,使用两个簇中所有数据点之间的最大距离;而当 linkage
值为 single
时,使用最小距离;当 linkage
值为 average
时,取所有样本对之间所有距离的平均值。当 linkage
值为 ward
时,如果两个簇中每个数据点与合并簇的质心之间的平均欧几里德距离最小,则合并两个簇,只有欧几里得距离可以与 ward linkage
一起使用。
为了能够比较上述不同 linkage
参数,我们创建一个新数据集,数据点以两个同心圆的形式排列。 make_circles
函数指定要生成的样本数 (n_samples
)、两个圆的距离 (factor
) 以及数据的所含噪声的数量 (noise
):
from sklearn.datasets import make_circles
x, y = make_circles(n_samples=150, factor=0.5, noise=0.05)
df_circles = pd.DataFrame({'x1': x[:,0], 'x2': x[:,1], 'y': y})
首先,我们使用层次聚类算法对新数据样本进行聚类,并使用不同的 linkage
参数值,同时使用参数 affinity
指定曼哈顿距离 manhattan
:
from sklearn.cluster import AgglomerativeClustering
linkage_options = ['complete', 'single']
fig, axs = plt.subplots(1, len(linkage_options) + 1, figsize=(14, 6))
x, y = df_circles[['x1', 'x2']], df_circles['y']
plot_2d_clusters(x, y, axs[0])
axs[0].set_title(f'{axs[0].get_title()}\nActuals')
for i, linkage in enumerate(linkage_options, 1):
y_pred = AgglomerativeClustering(n_clusters=2, affinity='manhattan', linkage=linkage).fit_predict(x)
plot_2d_clusters(x, y_pred, axs[i])
axs[i].set_title(f'{axs[i].get_title()}\nAgglomerative\nLinkage={linkage}')
plt.show()
以下是代码执行的结果:
当 linkage
值为 single
时,使用每个簇对之间的最短距离,可以识别数据点所在的圆形条带。当 linkage
值为 single
时,使用簇对之间的最长距离。显然,single
值有最好的结果,然而,由于其方差会受到噪声的影响。我们可以在将噪声从 0.05
增加到 0.08
后再次生成圆形数据集,观察不同效果,如下所示:
from sklearn.datasets import make_circles
x, y = make_circles(n_samples=150, factor=0.5, noise=0.08)
df_circles = pd.DataFrame({'x1': x[:,0], 'x2': x[:,1], 'y': y})
from sklearn.cluster import AgglomerativeClustering
linkage_options = ['complete', 'single']
fig, axs = plt.subplots(1, len(linkage_options) + 1, figsize=(14, 6))
x, y = df_circles[['x1', 'x2']], df_circles['y']
plot_2d_clusters(x, y, axs[0])
axs[0].set_title(f'{axs[0].get_title()}\nActuals')
for i, linkage in enumerate(linkage_options, 1):
y_pred = AgglomerativeClustering(
n_clusters=2, affinity='manhattan', linkage=linkage).fit_predict(x)
plot_2d_clusters(x, y_pred, axs[i])
axs[i].set_title(f'{axs[i].get_title()}\nAgglomerative\nLinkage={linkage}')
plt.show()
在新数据样本上运行相同的聚类算法,可以得到以下结果:
可以看到,当 linkage
值为 single
时,落在两个簇之间的噪声点可能会导致簇之间的合并。
算法执行时,必须为 K-means
和层次聚类算法预定义所需的簇数量。与 K-means
算法相比,层次聚类的计算代价较高,而 K-means
算法不能处理非凸数据。在下一节中,将看学习第 3 种聚类算法——DBSCAN
,它不需要预先定义簇的数量。
DBSCAN
是 Density-Based Spatial Clustering of Applications with Noise 的缩写,它将簇定义为密度相连的点的最大集合,能够把具有足够高密度的区域划分为簇,并可在包含噪声的空间数据中发现任意形状的聚类。这与 K-means
算法不同,K-means
算法需要假设数据集是凸的,即具有质心的数据。 DBSCAN
算法首先识别核心样本,这些核心在
ε
ε
ε (eps
) 的距离内至少有 min_samples
个样本,开始时,簇是根据其核心样本构建的。确定核心样本后,还会检查其邻居并将其添加到簇中。然后,扩展簇,以便我们可以向其中添加非核心样本,这些样本是距离核心样本
ε
ε
ε 内的样本,但它们本身不是核心样本。一旦确定了所有簇,剩下的样本就被认为是噪声。
很明显,min_samples
和 eps
超参数在最终预测中起着重要作用,我们将 min_samples
设置为 3
并为 eps
设置不同的值:
from sklearn.cluster import DBSCAN
eps_options = [0.1, 1.0, 2.0, 5.0]
fig, axs = plt.subplots(1, len(eps_options) + 1, figsize=(14, 6))
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
plot_2d_clusters(x, y, axs[0])
axs[0].set_title(f'{axs[0].get_title()}\nActuals')
for i, eps in enumerate(eps_options, 1):
y_pred = DBSCAN(eps=eps, min_samples=3,
metric='euclidean').fit_predict(x)
plot_2d_clusters(x, y_pred, axs[i])
axs[i].set_title(f'{axs[i].get_title()}\nDBSCAN\neps = {eps}')
plt.show()
使用 blobs
数据集的进行 DBSCAN
聚类,并查看不同 eps
超参数的影响:
较小的 eps
不会形成任何核心样本,当 eps
设置为 0.1
时,几乎所有的点都被视为噪声。随着 eps
值的增加,核心点开始形成。但是,在某些时候,当 eps
设置为 0.5
时,两个簇被错误地合并了。
同样,min_samples
的值也会决定聚类算法的性能,接下来,我们使用同心数据集观察不同的 min_samples
值对聚类算法的影响:
from sklearn.cluster import DBSCAN
min_samples_options = [3, 5, 10]
fig, axs = plt.subplots(1, len(min_samples_options) + 1, figsize=(14, 6))
x, y = df_circles[['x1', 'x2']], df_circles['y']
plot_2d_clusters(x, y, axs[0])
axs[0].set_title(f'{axs[0].get_title()}\nActuals')
for i, min_samples in enumerate(min_samples_options, 1):
y_pred = DBSCAN(
eps=0.25, min_samples=min_samples, metric='euclidean', n_jobs=-1
).fit_predict(x)
plot_2d_clusters(x, y_pred, axs[i])
axs[i].set_title(f'{axs[i].get_title()}\nDBSCAN\nmin_samples = {min_samples}')
plt.show()
在下图中,可以看到 min_samples
对聚类结果的影响:
与 eps
相比,min_samples
的值越大,核心样本越难形成。
除了以上超参数,我们还可以改变算法使用的距离度量。通常,min_samples
取值大于 3
。将 min_samples
设置为 1
意味着每个样本将形成 1
个簇,而将其设置为 2
的结果与层次聚类算法结果相似。我们可以首先将 min_samples
值设置为数据的特征数量的两倍。然后,如果数据中包含噪声,可以增加 min_samples
值,否则减少 min_samples
值。
在本节中,我们介绍了无监督学习中最重要的算法之一——聚类,并学习了 3
种不同的聚类算法,这 3
种算法中从不同的角度解决了聚类任务。K-means
聚类算法试图找到能够归纳数据样本的质心,并围绕它们构建簇。层次聚类方法是一种自下而上的方法,而 DBSCAN
聚类算法引入了核心和密度等概念。同时,由于数据集中缺少标签,因此监督学习中的大多评价标准并不能用于聚类算法,我们本节中还介绍了新的评估指标用于评价聚类算法,例如调整兰德系数和轮廓系数。
使用Scikit-learn开启机器学习之旅
一文开启深度学习之旅
一文开启计算机视觉之旅
一文开启自然语言处理之旅
一文开启监督学习之旅
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)