背景与目标###
Youzan 是一家SAAS公司,服务于数百万商家,帮助互联网时代的生意人私有化顾客资产、拓展互联网客群、提高经营效率。现在,该公司希望能够从商家的交易数据中,挖掘出有强烈续费倾向的商家,并提供更优质更有针对性的服务。
目标: 从商家交易数据中识别有强烈续费倾向的商家。
思路与建模###
kNN是一种思路简单清晰的有点近似蛮力的机器学习算法。它将待分类数据的特征值集与已分类数据集的每个样本的特征值集进行比较,计算出距离值,然后根据距离最小原则,选择k个距离最小的已分类实例,从这k个已分类实例中选择分类概率最大(出现次数最多)的那个。
从商家交易数据中识别有强烈续费倾向的商家,首先进行建模:
商家的强烈续费意向与哪些交易数据密切关联呢?这里,作出如下假设: 商家的强烈续费意向与“日均成交订单数”、“日均实际成交金额”、“日均累计粉丝数” 三个特征值关联。 下面,就说明运用kNN算法识别潜在续费商家的步骤。 完整源代码见附录。
本文的源代码来自《机器学习实战》 第二章。实现用到了矩阵计算,在代码讲解中会细说。
步骤与实现###
训练样本集####
首先需要准备一个训练样本集,里面含有每个商家的“日均成交订单数”、“日均实际成交金额”、“日均累计粉丝数”、“是否已续费”。为方便起见,可以准备一个原始的数据文件 origin.txt ,然后通过随机值增加的方式(见源代码中的 createMoreSamplesFrom(filename, n)) 生成更大的实际样本集 sample.txt。 这里数据都是伪造的。
12 789 11 0
1089 1200000 134 1
10 9800000 789 1
244 98700 256 0
1234 987653 900 0
40000 50000000 14000 1
30000 600000 120000 1
2500 4700000 9000 1
3 278 55 0
5890 2457788 130000 1
702 89032 890 0
12456 87200012 75000 1
125 90321133 45001 1
600 300020 120000 1
1456 90224441 900000 1
456 12456 2356 0
8 1204 236 0
129000 135700000 8000000 1
数据归一化####
可以看到,日均实际成交金额的值非常大,因此特征值“日均实际成交金额”的距离会起决定性的作用,弱化其他特征值的影响。可以采用数据归一化,使得特征值的绝对值影响弱化。 见函数 autoNorm(dataset)。
对于一个序列 x = [x1, x2, ..., xN] 来说, 归一化数值 xi = (xi - min(x)) / (max(x) - min(x)) 。
矩阵归一化的计算逻辑如下: 假设 x = [[16,4,9], [3,15,27]] ,
step1: 计算每列的最小值 min(x) = [3,4,9] 以及最大值 max(x) = [16,15,27]
step2: 计算最大值与最小值之间的距离:ranges = max(x) - min(x) = [13,11,18]
step3: 计算每行的归一化数值为 ([16,4,9] - [3,4,9]) / [13,11,18] = [1,0,0] , ( [3, 15, 27] - [3,4,9] ) / [13,11,18] = [0, 1, 1] ;这里都是逐个值对应计算。
step4: 得到原矩阵 x 的归一化数值为 [[1,0,0], [0,1,1]]
def autoNorm(dataset):
minVals = dataset.min(0)
maxVals = dataset.max(0)
ranges = maxVals - minVals
normDataset = zeros(shape(dataset))
rows = dataset.shape[0]
normDataset = dataset - tile(minVals, (rows,1))
normDataset = true_divide(normDataset, tile(ranges, (rows,1)))
return normDataset, ranges, minVals
可视化数据####
可以通过图形初步展示数据的形状, 见函数 draw(dataset)
计算距离向量####
得到归一化数据后,可以计算输入向量与每一个样本实例的距离,得到距离向量。见函数 computeDistance(inX, dataset) 。 这里 inX 是输入向量,dataset 是训练样本矩阵。
这里为了计算 inX 与每一个样本实例的距离值,需要将 inX 在行方向上平铺N次,N 是 dataset 的总行数,因此使用 tile(inX, (dataset.shape[0],1)); 然后进行矩阵减法和平方,再将每行的平方和进行求根,得到输入向量与每个样本实例的距离。
比如 inX = [0.1,0.5,0.8] , dataset = [[1,0,0], [0,1,1]] , 距离向量为 D; D.rows(1) = ((-0.9)^2 + (0.5)^2 + (0.8) 2)(0.5) = 1.3038 ; D.rows(2) = (0.01 + 0.25 + 0.04)^(0.5) = 0.5477 ; D = [1.3038, 0.5477]
def computeDistance(inX, dataset):
datasetrows = dataset.shape[0]
diffMat = tile(inX, (datasetrows, 1)) - dataset
sqDiffMat = diffMat ** 2
sqDistances = sqDiffMat.sum(axis=1)
distances = sqDistances ** 0.5
return distances
分类算法####
得到距离向量后,就可以进行分类了。先对距离向量从小到大进行排序,然后取前k个样本实例的分类值 class[k],接着计算这k个分类值中哪个分类值的出现概率最大。 见 函数 classify(inX, dataset, labels, k) , 这里 labels 是样本集的分类标签向量,k 是算法的设置变量,取前k个距离最近的样本的分类标签。
distances = computeDistance(inX, dataset)
sortedDistIndicies = distances.argsort()
classCount = {}
for i in range(k):
voteLabel = labels[sortedDistIndicies[i]]
classCount[voteLabel] = classCount.get(voteLabel, 0) + 1
sortedClassCount = sorted(classCount.iteritems(), key=operator.itemgetter(1), reverse=True)
return sortedClassCount[0][0]
分类算法的错误率####
评价分类算法的错误率是非常重要的。见函数 computeErrorRatio。
将已含分类标签值的样本集实例TotalSample分为两类,一个是用于计算距离向量的样本集 Sample,一个用于测试分类错误率的输入向量集合 inMatrix。一般 inMatrix 占总TotalSample的 10%,可以作为算法设置 ratio 。 对于 inMatrix 的每个输入向量,得到其分类标签值,然后与其已有的分类值比较,得到正确或错误的分类结果。分类算法的错误率 = 分类错误的结果数 / inMatrix 的总数。
对未知数据分类####
假设这个分类算法的错误率在接受范围内,那么就可以使用这个分类算法对未知数据分类了。注意,未知数据也要进行归一化。 见函数 classifyInstance(dataMatrix,classLabelVector, ranges, minVals)。
从其他数据文件读取待分类的数据,每行一个, 转换成输入向量 inX ,然后使用 classify 函数进行分类即可得到其分类值。
至此,kNN 算法的基本步骤和实现就完成了。
优化方向###
可以从“可扩展性”和“性能”来优化kNN算法的代码实现。
可扩展性####
在模型中增加更多的因素(比如下单/付款转化率,服务满意度等),可以不改变代码而依然执行。由于整个计算都是基于转化后的矩阵,而输入和输出都是以整个元组为单位,没有耦合业务性的特征值或特征值的数目,因此具备可扩展性。
性能####
主要性能开销集中在计算距离 computeDistance 方法上。 通过将矩阵分块,使用并发或分布式算法,能够在多台机器上运行 kNN 算法,实现对大规模数据样本集的kNN计算。
下面的实现,现将样本数据集 dataset 按行分割为 N 块,分别计算每一块的子距离向量,然后将多个子距离向量合并成一个最终的距离向量。
def divideNParts(total, N):
'''
divide [0, total) into N parts:
return [(0, total/N), (total/N, 2M/N), ((N-1)*total/N, total)]
'''
each = total / N
parts = []
for index in range(N):
begin = index*each
if index == N-1:
end = total
else:
end = begin + each
parts.append((begin, end))
return parts
def fastComputeDistance(inX, dataset, n):
totalrows = dataset.shape[0]
parts = divideNParts(totalrows, n)
partsResult = getPartResults(inX, dataset, parts)
return array(flatten(partsResult))
def flatten(multiDimenList):
return [item for sublist in multiDimenList for item in sublist]
def getPartResults(inX, dataset, parts):
# should use concurrent implementation
partsResult = []
for part in parts:
partsResult.append(computeDistance(inX, dataset[part[0]:part[1], :]))
return partsResult
完整源代码###
需要安装如下包: numpy, matplotlib, fontconfig, operator
# -*- coding: utf-8 -*-
# -------------------------------------------------------------------------------
# Name: potiential_users.py
# Purpose: recognise potiential renewal users using kNN algorithm
#
# Author: qin.shuq
#
# Created: 2018/03/10
#-------------------------------------------------------------------------------
#!/usr/bin/env python
import random
import math
import matplotlib
import matplotlib.pyplot as plot
from numpy import *
import operator
indicatorNumber = 3
k = 5
def createMoreSamplesFrom(filename, n):
datar = open(filename)
lines = datar.readlines()
datar.close()
lineNum = len(lines)
totalLines = lineNum * n
dataw = open('sample.txt', 'w')
for i in range(totalLines):
data = map (lambda x: int(x), lines[random.randint(0,lineNum)].strip().split())
newdata = []
for j in range(indicatorNumber):
newdata.append(str(data[j] + random.randint(0, math.pow(8, j)*10)))
classifierResult = data[-1]
dataw.write('%s %d\n' % (' '.join(newdata), classifierResult))
dataw.close()
def file2matrix(filename):
dataf = open(filename)
lines = dataf.readlines()
dataf.close()
numOfLines = len(lines)
dataMatrix = zeros((numOfLines, indicatorNumber))
classLabelVector = []
index = 0
for line in lines:
line = line.strip()
listFromLine = line.split()
dataMatrix[index, :] = listFromLine[0:indicatorNumber]
classLabelVector.append(int(listFromLine[-1]))
index += 1
return (dataMatrix, classLabelVector)
def autoNorm(dataset):
minVals = dataset.min(0)
maxVals = dataset.max(0)
ranges = maxVals - minVals
normDataset = zeros(shape(dataset))
rows = dataset.shape[0]
normDataset = dataset - tile(minVals, (rows,1))
normDataset = true_divide(normDataset, tile(ranges, (rows,1)))
return normDataset, ranges, minVals
def computeDistance(inX, dataset):
datasetrows = dataset.shape[0]
diffMat = tile(inX, (datasetrows, 1)) - dataset
sqDiffMat = diffMat ** 2
sqDistances = sqDiffMat.sum(axis=1)
distances = sqDistances ** 0.5
return distances
def divideNParts(total, N):
'''
divide [0, total) into N parts:
return [(0, total/N), (total/N, 2M/N), ((N-1)*total/N, total)]
'''
each = total / N
parts = []
for index in range(N):
begin = index*each
if index == N-1:
end = total
else:
end = begin + each
parts.append((begin, end))
return parts
def fastComputeDistance(inX, dataset, n):
totalrows = dataset.shape[0]
parts = divideNParts(totalrows, n)
partsResult = getPartResults(inX, dataset, parts)
return array(flatten(partsResult))
def flatten(multiDimenList):
return [item for sublist in multiDimenList for item in sublist]
def getPartResults(inX, dataset, parts):
# should use concurrent implementation
partsResult = []
for part in parts:
partsResult.append(computeDistance(inX, dataset[part[0]:part[1], :]))
return partsResult
def classify(inX, dataset, labels, k):
distances = fastComputeDistance(inX, dataset, 4)
sortedDistIndicies = distances.argsort()
classCount = {}
for i in range(k):
voteLabel = labels[sortedDistIndicies[i]]
classCount[voteLabel] = classCount.get(voteLabel, 0) + 1
sortedClassCount = sorted(classCount.iteritems(), key=operator.itemgetter(1), reverse=True)
return sortedClassCount[0][0]
def draw(data):
fig = plot.figure()
ax = fig.add_subplot(111)
ax.scatter(data[:, 0], data[:,1])
#ax.scatter(data[:, 0], data[:,1], 20.0*array(classLabelVector), 20.0*array(classLabelVector))
plot.title('potiential renewal users figure')
plot.xlabel('daily order number')
plot.ylabel('daily actual deal')
#plot.show()
def computeErrorRatio(dataMatrix, classLabelVector):
testRatio = 0.10
totalRows = dataMatrix.shape[0]
testRows = int(totalRows*testRatio)
errorCount = 0
for i in range(testRows):
classifierResult = classify(dataMatrix[i,:], dataMatrix[testRows:totalRows, :], classLabelVector[testRows:totalRows], k)
print 'classify result: %d, the real result is %d' % (classifierResult, classLabelVector[i])
if classifierResult != classLabelVector[i]:
errorCount += 1.0
print 'total error rate is %f' % (errorCount / float(testRows))
def classifyInstance(dataMatrix,classLabelVector, ranges, minVals):
dataf = open('data.txt')
for line in dataf:
line = line.strip()
# the first line is id of data, should not be used for distance computation
data = map(lambda x: int(x), line.split())
input = array(list(data[1:len(data)]))
classifierResult = classify((input-minVals)/ranges, dataMatrix, classLabelVector, k)
features = " ".join(map(lambda x: str(x), data[1:]))
print '%d (%s) is %s potiential renewal user' % (data[0], features, "not" if classifierResult != 1 else "" )
def test():
x = array([[16,4,9], [3,15,27], [12,1,18], [20, 7, 15], [7,9,15], [23,8,11], [5,17,26], [3,10,6]])
xNorm = autoNorm(x)
print 'x: ', x , 'norm(x): ', xNorm
inX = array([0.1,0.5,0.8])
slowComputeDistances = computeDistance(inX, xNorm[0])
print 'slow distances: ', slowComputeDistances
for npart in [1,2,3,4]:
fastComputeDistances = fastComputeDistance(inX, xNorm[0],npart)
print 'fast distances: ', fastComputeDistances
for ind in range(slowComputeDistances.shape[0]):
slowComputeDistances[ind] == fastComputeDistances[ind]
if __name__ == '__main__':
test()
createMoreSamplesFrom('origin.txt', 10)
(dataMatrix,classLabelVector) = file2matrix('sample.txt')
(dataMatrix,ranges, minVals) = autoNorm(dataMatrix)
#print 'dataset: ', (dataMatrix,classLabelVector)
draw(dataMatrix)
computeErrorRatio(dataMatrix, classLabelVector)
classifyInstance(dataMatrix,classLabelVector, ranges, minVals)
矩阵函数####
- array([[...]]): 将Python多维数组转化为相应的N维矩阵从而可以进行计算;
- shape: 得到该矩阵的维度属性,是一个元组; m.shape[0],行数 ; m.shape[1] , 列数 ;
- min(n): 得到该矩阵指定维度的最小值: m.min(0),每列的最小值 ; m.min(1), 每行的最小值;n 不大于 shape 的长度;
- max(n): 得到该矩阵指定维度的最大值: m.max(0), 每列的最大值;m.max(0),每行的最大值 n 不大于 shape 的长度;
- tile: 矩阵平铺,从指定的行方向/列方向将原矩阵进行平铺得到新的矩阵;
- ravel: 多维数组平铺成低维数组
- argsort: 将向量从小到大排序,排序后的每个元素在原向量中的索引值形成一个新的索引向量。
>>> import numpy as np
>>> x = np.array([[16,4,9], [3,15,27]])
>>> x
array([[16, 4, 9],
[ 3, 15, 27]])
>>> x.shape # 得到 x 的维度信息
(2, 3)
>>> x.shape[0] # 总行数
2
>>> x.shape[1] # 总列数
3
>>> x.min(0) # 列方向的最小值
array([3, 4, 9])
>>> x.min(1) # 行方向的最小值
array([4, 3])
>>> x.max(0) # 列方向的最大值
array([16, 15, 27])
>>> x.max(1) # 行方向的最大值
array([16, 27])
>>>
>>>
>>> np.tile(x, (2,1)) # 将 x 在列方向平铺2次
array([[16, 4, 9],
[ 3, 15, 27],
[16, 4, 9],
[ 3, 15, 27]])
>>> np.tile(x,(1,2)) # 将 x 在行方向平铺2次
array([[16, 4, 9, 16, 4, 9],
[ 3, 15, 27, 3, 15, 27]])
>>>
>>> np.tile(x, (2,2)) # 将 x 在行和列方向分别平铺2次
array([[16, 4, 9, 16, 4, 9],
[ 3, 15, 27, 3, 15, 27],
[16, 4, 9, 16, 4, 9],
[ 3, 15, 27, 3, 15, 27]])
>>> x = np.array([[1,2,3], [1,4,5]])
>>> np.ravel(x)
array([1, 2, 3, 1, 4, 5])
>>> x=np.array([1,4,3,-1,6,9])
>>> x.argsort()
array([3, 0, 2, 1, 4, 5]) # -1 是最小值,在原向量中 index = 3
小结###
本文讲解了如何使用kNN算法来实现识别潜在续费商家。kNN算法依赖于模型的正确性。如果分类标签值与样本特征值有非常密切的关联,使用简单的kNN算法即可得到有效的结果,而且不限于特定的应用领域。只要能够将领域问题转化为样本特征值矩阵,就能使用 kNN 算法来进行求解。
这也是我学习的第一个机器学习算法