Fork me on GitHub

机器学习实战-3-基于KNN的约会网站配对实现

机器学习实战-3-KNN算法实战

本文中介绍的是《机器学习实战》一书中关于KNN算法的一个实战案例:海伦约会案例

海伦约会

整体过程

  1. 收集数据:提供文本文件
  2. 准备数据:通过pandas来读取数据
  3. 分析数据:通过matplotlib来绘制散点图
  4. 测试算法:将海伦提供的数据随机分成训练集和测试集

背景

海伦女士一直在使用约会网站来寻找适合自己的约会对象。尽管约会网站会推荐不同的人选,但是海伦不是喜欢每个人。经过一番的总结,她发现自己喜欢过3个类型的人:

  • 完全不喜欢的人
  • 魅力一般的人
  • 极具魅力的人

海伦自己通过一段时间搜集一份数据,她将这些数据存放在文本文件datingTestSet中,每个样本数据占据一行,总共有1000行,主要包含以下3种特征:

  • 每年获得的飞行常客里程数
  • 玩视频游戏所消耗时间百分比
  • 每周消费的冰淇淋公升数

真心吃货,冰淇淋都能成为其找对象的指标😃

数据的大致样貌如下:

准备数据

在将上面的数据输入到分类器之前,必须将待处理的数据格式改变为分类器可以接受的格式。分类器接受的数据格式分为两个部分:

  • 特征矩阵:数据部分
  • 数据标签:分类标签

因此将文本记录转成Numpy的解析程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import numpy as np
"""
函数说明:打开文件并解析,对数据进行分类:1-不喜欢 2-魅力一般 3-极具魅力

参数:
filename 文件名
返回值:
returnMat 特征矩阵
classLabelVector 分类标签向量

修改时间:
2021-02-28
"""
def file2matrix(filename):
# 打开文件
fr = open(filename)
# 读取文件全部内容
arrayOLines = fr.readlines()
# 得到文件行数
numberOfLines = len(arrayOLines)
# 返回的Numpy矩阵,解析完成的数据:numberOfLines * 3的大小
returnMat = np.zeros((numberOfLines, 3)) # 全0矩阵
# 返回的分类标签向量
classLabelVector = []
# 行的索引值
index = 0

for line in arrayOLines: # 对全部内容进行遍历循环
# 删除空白字符(\n,\r,\t,' ')
line = line.strip()
# 根据\t进行切割
listFromLine = line.split('\t')
# 将数据的前3列取出来,存放在returnMat的特征矩阵中
returnMat[index,:] = listFromLine[0:3]
# 根据文本标记的喜欢程度进行分类:1-不喜欢 2-魅力一般 3-极具魅力
if listFromLine[-1] == 'didntLike':
classLabelVector.append(1)
elif listFromLine[-1] == 'smallDoses':
classLabelVector.append(2)
elif listFromLine[-1] == 'largeDoses':
classLabelVector.append(3)
index += 1 # 每次循环索引值加1,对returnMat有用
return returnMat, classLabelVector # 返回特征矩阵和分类标签


if __name__ == "__main__":
filename = "datingTestSet.txt"
returnMat, classLabelVector = file2matrix(filename)
print(returnMat)
print(classLabelVector)

调用上面的函数并执行观察效果:

数据可视化

在上面我们已经顺利导入了数据,并且进行了解析,格式化为分类器需要的数据,接下来需要通过可视化的方式来直观地呈现数据,得到一些初步的结论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# -*- coding: UTF-8 -*-
from matplotlib.font_manager import FontProperties
import matplotlib.lines as mlines
import matplotlib.pyplot as plt
import numpy as np

"""
函数说明:打开数据并解析,对数据进行分类:1-不喜欢 2-一般魅力 3-极具魅力
参数:
filename 文件名
返回值:
returnMat 特征矩阵
classLabelVector 分类标签向量
修改时间:
2021-02-28
"""

def file2matrix(filename):
# 打开文件
fr = open(filename)
# 读取文件全部内容
arrayOLines = fr.readlines()
# 得到文件行数
numberOfLines = len(arrayOLines)
# 返回的Numpy矩阵,解析完成的数据:numberOfLines * 3的大小
returnMat = np.zeros((numberOfLines, 3)) # 全0矩阵
# 返回的分类标签向量
classLabelVector = []
# 行的索引值
index = 0

for line in arrayOLines: # 对全部内容进行遍历循环
# 删除空白字符(\n,\r,\t,' ')
line = line.strip()
# 根据\t进行切割
listFromLine = line.split('\t')
# 将数据的前3列取出来,存放在returnMat的特征矩阵中
returnMat[index,:] = listFromLine[0:3]
# 根据文本标记的喜欢程度进行分类:1-不喜欢 2-魅力一般 3-极具魅力
if listFromLine[-1] == 'didntLike':
classLabelVector.append(1)
elif listFromLine[-1] == 'smallDoses':
classLabelVector.append(2)
elif listFromLine[-1] == 'largeDoses':
classLabelVector.append(3)
index += 1 # 每次循环索引值加1,对returnMat有用
return returnMat, classLabelVector


"""
函数说明:数据可视化
参数:
- 特征矩阵
- 分类标签
返回值:无

时间:2021-02-28
"""

def showdatas(returnMat, classLabelVector):
# 显示中文配置:Songti SC系统中的中文字体之一
plt.rcParams['font.sans-serif']=['Songti SC'] # 用来正常显示中文标
plt.rcParams['axes.unicode_minus']=False # 用来正常显示负号
# 设置2*2的画布
fig,axs = plt.subplots(nrows=2, ncols=2,sharex=False,sharey=False,figsize=(13,8))

# 特征数据长度
numberOfLabels = len(returnMat)
# 设置空标签来存储
LabelsColors = []
for i in classLabelVector: # 对每个标签遍历,并且赋上对应的颜色
if i == 1:
LabelsColors.append('black')
if i == 2:
LabelsColors.append('orange')
if i == 3:
LabelsColors.append('red')

# 画出散点图:散点大小15 透明度0.5
axs[0][0].scatter(x=returnMat[:,0],y=returnMat[:,1],color=LabelsColors,s=15,alpha=0.5)

# 设置标题、x轴、y轴
axs0_title_text = axs[0][0].set_title(u'每年获得的飞行常客里程数与玩视频游戏所消耗时间占比')
axs0_xlabel_text = axs[0][0].set_xlabel(u'每年获得的飞行常客里程数')
axs0_ylabel_text = axs[0][0].set_ylabel(u'玩视频游戏所消耗时间占比')
plt.setp(axs0_title_text, size=9, weight='bold', color='red')
plt.setp(axs0_xlabel_text, size=7, weight='bold', color='black')
plt.setp(axs0_ylabel_text, size=7, weight='bold', color='black')

# 每年获得的飞行常客里程数与每周消费的冰激淋公升数
axs[0][1].scatter(x=returnMat[:,0],y=returnMat[:,2],color=LabelsColors,s=15,alpha=0.5)
# 设置标题、x轴、y轴
axs1_title_text = axs[0][1].set_title(u'每年获得的飞行常客里程数与每周消费的冰激淋公升数')
axs1_xlabel_text = axs[0][1].set_xlabel(u'每年获得的飞行常客里程数')
axs1_ylabel_text = axs[0][1].set_ylabel(u'每周消费的冰激淋公升数')
plt.setp(axs1_title_text, size=9, weight='bold', color='red')
plt.setp(axs1_xlabel_text, size=7, weight='bold', color='black')
plt.setp(axs1_ylabel_text, size=7, weight='bold', color='black')

# 玩视频游戏所消耗时间占比与每周消费的冰激淋公升数
axs[1][0].scatter(x=returnMat[:,1],y=returnMat[:,2],color=LabelsColors,s=15,alpha=0.5)
# 设置标题、x轴、y轴
axs2_title_text = axs[1][0].set_title(u'玩视频游戏所消耗时间占比与每周消费的冰激淋公升数')
axs2_xlabel_text = axs[1][0].set_xlabel(u'玩视频游戏所消耗时间占比')
axs2_ylabel_text = axs[1][0].set_ylabel(u'每周消费的冰激淋公升数')
plt.setp(axs2_title_text, size=9, weight='bold', color='red')
plt.setp(axs2_xlabel_text, size=7, weight='bold', color='black')
plt.setp(axs2_ylabel_text, size=7, weight='bold', color='black')

# 设置图例
didntLike = mlines.Line2D([], [], color='black', marker='.',
markersize=6, label='didntLike')
smallDoses = mlines.Line2D([], [], color='orange', marker='.',
markersize=6, label='smallDoses')
largeDoses = mlines.Line2D([], [], color='red', marker='.',
markersize=6, label='largeDoses')
#添加图例
axs[0][0].legend(handles=[didntLike,smallDoses,largeDoses])
axs[0][1].legend(handles=[didntLike,smallDoses,largeDoses])
axs[1][0].legend(handles=[didntLike,smallDoses,largeDoses])
#显示图片
plt.show()


if __name__ == "__main__":
# 打开的文件名
filename = "datingTestSet.txt"
# 处理数据
returnMat, classLabelVector = file2matrix(filename)
# 显示图片
showdatas(returnMat, classLabelVector)

数据归一化

下表中给出了一部分数据,如果想计算样本3和样本4之间的距离,可以使用欧式距离的公式来进行计算:

样本 玩游戏所耗时间占比 每年获得的飞行里程数 每周消耗的冰淇淋公升数 样本分类
1 0.8 4000 0.5 1
2 12 134000 1 3
3 0 20000 1.2 2
4 62 32000 0.3 2

计算公式如下图所示:

$$\sqrt{(0-62)2+(20000-32000)2+(1.2-0.3)^2}$$

我们发现:上面方程中数字差值最大的属性对计算结果的影响是最大的,也就是说:

每年获取的飞行里程数对于计算结果的影响是远大于其他两个特征的

但是在海伦的心中:这3个因素是同等重要的,因此作为作为3个等权重的特征之一,飞行里程数并不应该严重地影响到计算的结果。

在处理这种不同取值范围的特征值时,我们通常采用的是归一化的方法,将取值范围控制在0-1或者-1到1之间,常用的归一化方法有:

  • 0-1标准化
  • Z-score标准化
  • Sigmoid压缩法

下面是一个0-1归一化的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import numpy as np
"""
函数说明:打开文件并解析,对数据进行分类:1-不喜欢 2-魅力一般 3-极具魅力

参数:
filename 文件名
返回值:
returnMat 特征矩阵
classLabelVector 分类标签向量

修改时间:
2021-02-28
"""
def file2matrix(filename):
# 打开文件
fr = open(filename)
# 读取文件全部内容
arrayOLines = fr.readlines()
# 得到文件行数
numberOfLines = len(arrayOLines)
# 返回的Numpy矩阵,解析完成的数据:numberOfLines * 3的大小
returnMat = np.zeros((numberOfLines, 3)) # 全0矩阵
# 返回的分类标签向量
classLabelVector = []
# 行的索引值
index = 0

for line in arrayOLines: # 对全部内容进行遍历循环
# 删除空白字符(\n,\r,\t,' ')
line = line.strip()
# 根据\t进行切割
listFromLine = line.split('\t')
# 将数据的前3列取出来,存放在returnMat的特征矩阵中
returnMat[index,:] = listFromLine[0:3]
# 根据文本标记的喜欢程度进行分类:1-不喜欢 2-魅力一般 3-极具魅力
if listFromLine[-1] == 'didntLike':
classLabelVector.append(1)
elif listFromLine[-1] == 'smallDoses':
classLabelVector.append(2)
elif listFromLine[-1] == 'largeDoses':
classLabelVector.append(3)
index += 1 # 每次循环索引值加1,对returnMat有用
return returnMat, classLabelVector # 返回特征矩阵和分类标签

"""
函数作用:数值归一化
函数参数:
特征矩阵 returnMat
返回值:
归一化后的特征矩阵 normDataSet
数据范围 ranges
最小值 minVal
"""
def autoNormal(dataSet):ßßßß
# 获取最大值和最小值,二者的范围
minVal = dataSet.min()
maxVal = dataSet.max()
ranges = maxVal - minVal
# 矩阵的行列数shape
normDataSet = np.zeros(np.shape(dataSet))
# (原始值 - 最小值)
normDataSet = dataSet - np.tile(minVal, (m, 1))
# 除以 (max-min)
normDataSet = normDataSet / np.tile(ranges, (m, 1))
# 返回归一化数据、数据范围、最小值
return normDataSet, ranges, minVal


if __name__ == "__main__":
filename = "datingTestSet.txt"
returnMat, classLabelVector = file2matrix(filename)
normDataSet, ranges, minVal = autoNorm(returnMat)
print(normDataSet)
print(ranges)
print(minVal)

测试算法:验证分类器

上面我们已经按照需求处理了数据,并且对数据做了归一化处理,接下来我们将开展机器学习中一个重要的内容:评估算法的准确率。通常我们使用提供的数据中90%作为训练集,剩下的10%作为测试集去检验分类器的准确率。

10%的测试集是随机选择的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import numpy as np

"""
函数说明:KNN算法分类
函数参数:
inX 用于分类的数据集(测试集)
dataSet 用于训练的数据(训练集)
labels 分类标签
k 算法参数,选择距离最近的k个点
修改时间:
2021-02-28
"""
def classify0(inX, dataSet, labels, k):
dataSetSize = dataSet.shape[0] # 文件行数,即大小
diffMat = np.tile(inX, (dataSetSize, 1)) - dataSet # np.tile表示在两个方向上的重复次数,达到和原始数据相同的shape,以便能够相减
sqDiffMat = diffMat ** 2
sqDistances = sqDiffMat.sum(axis=1)
distances = sqDistances ** 0.5 # 以上3步:距离相减再平方,再求和,再开根号
# 获取到的是索引值!!!
sortedDistIndices = distances.argsort() # 全部距离从小到大排序后的索引值
classCount = {} # 存储类别次数的字典
for i in range(k):
voteIlabel = labels[sortedDistIndices[i]] # 根绝每个索引,取出对应的前k个元素的类别
classCount[voteIlabel] = classCount.get(voteIlabel,0) + 1 # 计算类别次数;get方法返回指定键的值,否则返回默认值


# python3中使用item()
# reverse表示降序排序字典
# key=operator.itemgetter(0)表示根据字典的键进行排序
# key=operator.itemgetter(1)表示根据字典的值进行排序
sortedClassCount = sorted(classCount.items(), key=operator.itemgetter(1),reverse=True)
return sortedClassCount[0][0] # 返回次数最多的类别,即所要分类的类别

"""
函数说明:打开文件并解析,对数据进行分类:1-不喜欢 2-魅力一般 3-极具魅力

参数:
filename 文件名
返回值:
returnMat 特征矩阵
classLabelVector 分类标签向量

修改时间:
2021-02-28
"""
def file2matrix(filename):
# 打开文件
fr = open(filename)
# 读取文件全部内容
arrayOLines = fr.readlines()
# 得到文件行数
numberOfLines = len(arrayOLines)
# 返回的Numpy矩阵,解析完成的数据:numberOfLines * 3的大小
returnMat = np.zeros((numberOfLines, 3)) # 全0矩阵
# 返回的分类标签向量
classLabelVector = []
# 行的索引值
index = 0

for line in arrayOLines: # 对全部内容进行遍历循环
# 删除空白字符(\n,\r,\t,' ')
line = line.strip()
# 根据\t进行切割
listFromLine = line.split('\t')
# 将数据的前3列取出来,存放在returnMat的特征矩阵中
returnMat[index,:] = listFromLine[0:3]
# 根据文本标记的喜欢程度进行分类:1-不喜欢 2-魅力一般 3-极具魅力
if listFromLine[-1] == 'didntLike':
classLabelVector.append(1)
elif listFromLine[-1] == 'smallDoses':
classLabelVector.append(2)
elif listFromLine[-1] == 'largeDoses':
classLabelVector.append(3)
index += 1 # 每次循环索引值加1,对returnMat有用
return returnMat, classLabelVector # 返回特征矩阵和分类标签

"""
函数说明:分类器测试函数
函数参数:无
返回值:
归一化后的特征矩阵 normDataSet
数据范围 ranges
数据最小值 minVal

修改时间:2021-02-28
"""

def datingClassTest():
filename = 'datingTestSet.txt'
# 得到特征矩阵和分类标签
returnMat, classLabelVector = file2matrix(filename)
# 归一化过程
normDataSet, ranges, minVal = autoNorm(returnMat)
# 测试集比例10%
hoRatio = 0.1
# 获得特征矩阵的行数
m = normDataSet.shape[0]
# 测试集的个数
numTestVecs = int(m * hoRation)
# 分类错误计数
errorCount = 0.0

for i in range(numTestVecs):
classifierResult = classify0(normDataSet[i,:], normDataSet[numTestVecs:m,:], dating)
print("分类结果: %d\t真实类别:%d"%(classifierResult,classLabelVector[i]))
if classifierResult != classLabelVector[i]:
errorCount += 1.0
print("错误率:%f%%"%(errorCount / float(numTestVecs)*100))

jupyter notebook中实现

导入库

1
2
3
4
5
6
7
8
9
import numpy as np
import pandas as pd
import plotly_express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots

%matplotlib inline
import matplotlib as mpl
import matplotlib.pyplot as plt

读取数据

1
2
data = pd.read_table('datingTestSet.txt',header=None)
data

查看数据信息

查看的主要信息包含

  • 大小shape

  • 字段类型、是否有缺失值

  • 统计值信息

绘制不同属性的散点图

使用matplotlib包绘制不同属性两两之间的散点图

1、对每行数据的标签进行颜色的标注

1
2
3
4
5
6
7
8
9
10
11
12
13
colors = []

for i in range(len(data)):
m = data.iloc[i,-2] # 样本分类的值进行判断赋值;我已经添加了一列新的数据,原始的数据标签在倒数第二列

if m == 'didntLike':
colors.append('black')
if m == 'smallDoses':
colors.append('orange')
if m == 'largeDoses':
colors.append('red')

colors[:20]

2、解决中文字体无法显示问题

一般情况下,通过下面的代码是可以直接在Jupyter notebook中显示中文的:

1
plt.rcParams['font.sans-serif']=['SimHei']  # 用来显示中文

但是可能自己的电脑系统中没有安装相应的字体,因此需要查看系统的字体,找到和中文相关的,查看系统字体的方法:

1
2
3
4
from matplotlib import font_manager
a = sorted([f.name for f in font_manager.fontManager.ttflist])
for i in a:
print(i) # 从A-Z的显示

看到了一个宋体,设置成宋体即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 显示中文配置:Songti SC 是上面查看到的字体之一

plt.rcParams['font.sans-serif']=['Songti SC'] # 用来正常显示中文标签
plt.rcParams['axes.unicode_minus']=False # 用来正常显示负号

# 设置画布大小
pl = plt.figure(figsize=(12,8))

# 设置3个子画布
fig1 = pl.add_subplot(221)
plt.scatter(data.iloc[:,0], # x轴数据
data.iloc[:,1], # y轴数据
marker='.', # 标记方式:原点
c=colors) # 颜色
plt.xlabel("飞行里程数")
plt.ylabel("玩游戏消耗百分比")

fig1 = pl.add_subplot(222)
plt.scatter(data.iloc[:,0],data.iloc[:,2],marker='.',c=colors)
plt.xlabel("飞行里程数")
plt.ylabel("每周冰淇淋公升数")

fig1 = pl.add_subplot(223)
plt.scatter(data.iloc[:,1],data.iloc[:,2],marker='.',c=colors)
plt.xlabel("玩游戏消耗百分比")
plt.ylabel("每周冰淇淋公升数")

plt.show()

使用plotly_express绘制图形

1
2
3
4
5
px.scatter(data,  # 绘图的数据
x="飞行里程数", # x轴
y="玩游戏消耗百分比", # y轴
color="对象标签" # 如何标记颜色
)

同理绘制另外两个图形:

数据归一化

在这里我们采用0-1标准化过程:(x-min) / (max - min),定义一个归一化函数:

1
2
3
4
5
6
7
8
## 定义归一化函数

def minmax(dataSet):
minD = dataSet.min()
maxD = dataSet.max()
normSet = (dataSet - minD) / (maxD - minD)

return normSet

对测试数据的归一化(只对数据部分归一化,标签不用):

1
2
3
4
# 1、只对数据部分归一化
# 2、归一化之后通过concat函数拼接起来
testNew = pd.concat([minmax(test.iloc[:,:3]), test.iloc[:,3]], axis=1)
testNew

切分训练集和测试集

对原始数据的归一化:

1
2
3
4
5
6
# 原始数据的归一化

# 1、只对数据部分归一化
# 2、归一化之后通过concat函数拼接起来
dataNew = pd.concat([minmax(data.iloc[:,:3]), data.iloc[:,3]], axis=1)
dataNew

取出前90%的数据,因为本身海伦收集的数据就是无任何特殊意义随机收集的,所以我们直接取出前90%作为训练集,剩下的作为测试集即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 海伦的数据本身就是无意义的排列,因此取出前90%作为训练集,后面的10%作为测试集

def randSplit(dataSet, rate=0.9):
n = dataSet.shape[0] # [1000,4] 取出1000,实际上就是data的长度len(data)
m = int(n * rate) # 取出前90%

train = dataSet.iloc[:m,:] # 前90%的行,所有列
test = dataSet.iloc[m:,:] # 后面的行及所有列

test.index = range(test.shape[0]) # 测试集test的上索引需要重置

return train,test


# 调用函数
train,test = randSplit(dataNew)

定义KNN分类器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def dataClassify(train,test,k):
n = train.shape[1] - 1 # train除去标签的所有列
m = test.shape[0] # test的行数
result = [] # 存放最终的结果

for i in range(m):
dist = list((((train.iloc[:,:n] - test.iloc[i,:n]) ** 2).sum(1))**0.5) # 计算训练集中的每个数据和测试集中某个数据的欧氏距离

dist_l = pd.DataFrame({'dist':dist, 'labels':(train.iloc[:,n])}) # 计算出来的距离和对应训练集的标签构成DF型数据

dr = dist_l.sort_values(by='dist')[:k] # 根据标签排序取出前k个数据
re = dr.loc[:,'labels'].value_counts() # 前k个数据中统计每个标签出现的票数,票数高的则为测试集数据的标签

result.append(re.index[0]) # re.index[0]表示票数最高的分类
result = pd.Series(result)
test['predict'] = result # 测试集中添加预测的结果
acc = (test.iloc[:,-1] == test.iloc[:,-2]).mean() # 原始结果和预测结果的对比
print(f"模型预测准确率{acc}")
return test

对测试集的数据调用分类器,显示出判断效果:

本文标题:机器学习实战-3-基于KNN的约会网站配对实现

发布时间:2021年02月28日 - 16:02

原始链接:http://www.renpeter.cn/2021/02/28/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0%E5%AE%9E%E6%88%98-3-%E5%9F%BA%E4%BA%8EKNN%E7%9A%84%E7%BA%A6%E4%BC%9A%E7%BD%91%E7%AB%99%E9%85%8D%E5%AF%B9%E5%AE%9E%E7%8E%B0.html

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

Coffee or Tea