《集体智慧编程》读书笔记7

最近重读《集体智慧编程》,这本当年出版的介绍推荐系统的书,在当时看来很引领潮流,放眼现在已经成了各互联网公司必备的技术。
这次边阅读边尝试将书中的一些Python语言例子用C#来实现,利于自己理解,代码贴在文中方便各位园友学习。

由于本文可能涉及到的与原书版权问题,请第三方不要以任何形式转载,谢谢合作。

第七部分 kNN算法

上一节的最后,给出了一个用方差来作为结果为数值的决策树评价的方法。这一部分我们针对结果为数值的数据集给出一种更好的预测算法。
针对数值型结果预测的算法最关键的工作就是确定哪些变量对结果的影响最大,这个可以通过前面文章介绍的“优化技术“(如模拟退火和遗传算法)来自动确定变量的权重。
这一部分将以商品价格预测作为例子,这是多个特征决定一个数值的结果的典型例子。

构造数据集

这个例子中我们模拟构造了一个关于葡萄酒价格的数据集。价格模型的确定方式是:酒的价格根据酒的等级及其储藏的年代共同决定,另外假设葡萄酒有”峰值年“概念,较之峰值年,年代早的葡萄酒品质更高,而峰值年之后的则品质稍差。高等级的葡萄酒的价格将从高位随着越接近峰值年价格越高。而低等级的葡萄酒价格从低位逐渐走低。
我们创建一个名为NumPredict的类并在其中加入WinePrice来模拟生成葡萄酒价格:

public double WinePrice(double rating, double age)
{
    var peakAge = rating - 50;
    //根据等级计算价格
    var price = rating / 2;
    if (age > peakAge)
    {
        //经过“峰值年”,后继5年里其品质将会变差
        price = price * (5 - (age - peakAge) / 2); //原书配书源码有/2,印刷版中没有是个错误,会导致为0的商品过多
    }
    else
    {
        //价格在接近“峰值年”时会增加到原值的5倍
        price = price * (5 * (age + 1) / peakAge);
    }
    if (price < 0)
        price = 0;
    return price;
}

然后再添加一个名为WineSet1用于模拟生产一批葡萄酒,并使用上面的方法制定葡萄酒的价格。最终的价格会在上面函数确定的价格基础上随机加减20%,这一是模拟税收,市场供应的客观情况对价格的影响,二来可以使数据更真实增加数值型预测的难度。

public List<PriceStructure> WineSet1()
{
    var rows = new List<PriceStructure>(300);
    var rnd = new Random();
    for (int i = 0; i < 300; i++)
    {
        //随机生成年代和等级
        var rating = rnd.NextDouble() * 50 + 50;
        var age = rnd.NextDouble() * 50;
        //得到参考价格
        var price = WinePrice(rating, age);
        //增加“噪声”
        price *= rnd.NextDouble() * 0.9 + 0.2; //配书代码的实现
        //加入数据集
        rows.Add(new PriceStructure()
        {
            Input = new[] { rating, age },
            Result = price
        });
    }
    return rows;
}

上面代码中我们还添加了一个内部类PriceStructure用于表示一瓶酒的价格形成结构。
接着我们测试下上面的代码,保证可以生成葡萄酒的价格数据集以用于后续的预测:

var numPredict = new NumPredict();
var price = numPredict.WinePrice(95, 3);
Console.WriteLine(price);
price = numPredict.WinePrice(95, 8);
Console.WriteLine(price);
price = numPredict.WinePrice(99, 1);
Console.WriteLine(price);
var data = numPredict.WineSet1();
Console.WriteLine(JsonConvert.SerializeObject(data[0]));
Console.WriteLine(JsonConvert.SerializeObject(data[1]));

由于是随机生成,每次构造的价格数据集都是不一样的。

k-最邻近算法

k-最邻近算法(k-nearest neighbors),简称kNN,的思想很简单,找到与所预测商品最近似的一组商品,对这些近似商品价格求均值来作为价格预测。

近邻数 - k

kNN中的k表示所查找的最近似的一组商品的数量,理想状况下,设置k为1会查找与待预测商品最近似的商品价格作为预测结果。
但实际情况中,会有如本例中故意加入的”噪声“这种干扰情况,使得最为进行的一个商品的价格不能最准确的反应待预测商品的价格。所以就需要通过选取k(k>1)个近似的商品并取其价格的均值来减少”噪声“影响。
当然如果选择过多的相似商品(较大的k值),也会导致均值产生不应有的偏差。

定义相似度

要使用kNN算法,第一个要做的就是确定判断两个商品相似度的方法。我们使用之前文章介绍过的欧几里德距离算法。
我们将算法函数Euclidean加入NumPredict

public double Euclidean(double[] v1, double[] v2)
{
    var d = v1.Select((t, i) => (double)Math.Pow(t - v2[i], 2)).Sum();
    return (double)Math.Sqrt(d);
}

接着来测试下欧几里德距离算法计算到的相似度:

var numPredict = new NumPredict();
var data = numPredict.WineSet1();
var similar = numPredict.Euclidean(data[0].Input, data[1].Input);
Console.WriteLine(similar);

目前的kNN算法所使用的相似度算法存在的问题,就是对不同因素的同样量度差别是同等看待的,而现实情况是一种因素往往产生的影响比另一种更大(同样量度下),后文将会介绍解决此问题的方法。

实现kNN算法

kNN的实现很简单,而且虽然其计算量较大,但可以进行增量训练。
首先,我们在NumPredict中添加计算距离列表的方法GeetDistances

private SortedDictionary<double, int> GetDistances(List<PriceStructure> data, double[] vec1)
{
    var distancelist = new SortedDictionary<double, int>(new RankComparer());
    for (int i = 0; i < data.Count; i++)
    {
        var vec2 = data[i].Input;
        distancelist.Add(Euclidean(vec1, vec2), i);
    }
    return distancelist;
}

class RankComparer : IComparer<double>
{
    public int Compare(double x, double y)
    {
        if (x == y) //这样可以让SortedList保存重复的key
            return 1;
        return x.CompareTo(y); //从小到大排序
    }
}

方法中我们使用SortedDictionary按距离进行了排序方便后面取前k个最近的项。
接着是knnestimate函数,其取上面列表的前k项并求平均值。

public double KnnEstimate(List<PriceStructure> data, double[] vec1, int k = 5)
{
    //得到经过排序的距离值
    var dlist = GetDistances(data, vec1);
    return dlist.Values.Take(k).Average(dv => data[dv].Result);
}

有了这些方法就可以对商品进行估价了。

var numPredict = new NumPredict();
var data = numPredict.WineSet1();
var estimate = numPredict.KnnEstimate(data, new [] { 95d, 3});
Console.WriteLine(estimate);
estimate = numPredict.KnnEstimate(data, new[] { 99d, 3});
Console.WriteLine(estimate);
estimate = numPredict.KnnEstimate(data, new[] { 99d, 5});
Console.WriteLine(estimate);
var real = numPredict.WinePrice(99, 5);
Console.WriteLine(real);
estimate = numPredict.KnnEstimate(data, new[] { 99d, 5}, 1);
Console.WriteLine(estimate);

上面的方法对比了预测价格与真实价格,并可以看到不同的k值对结果的影响。

为近邻分配权重

上面的算法中计算预测价格时采用k个近似的商品的均价而没有考虑这些商品的近似度不同。这一部分我们对这个问题进行一些修正,我们按照近似程度对这些商品的价格赋予一定的权重。将“距离”(相似度指标)转为权重有如下三种方法。

反函数

反函数即取距离的倒数作为权重。由于当距离极小(相似度极高)时,权重会极具变大(分母减小过快,会使倒数明显增大)。我们在计算权重前,给距离加上一个初始常量来避免这个问题。

将反函数的实现方法InverseWeight加入NumPredict中:

public double InverseWeight(double dist, double num = 1, double @const = 0.1f)
{
    return num / (dist + @const);
}

反函数计算速度很快,但其明显的问题是,随着相似度降低,权重衰减很快,有些情况下这可能会带来问题。

减法函数

减法函数是一个更简单的函数,其用一个常量值减去距离,如果结果大于0,则将结果作为权重,否则权重为0。
NumPredict中的SubtractWeight方法是减法函数的实现:

public double SubtractWeight(double dist, double @const = 1)
{
    if (dist > @const) return 0;
    return @const - dist;
}

这个方法的缺陷是,如果权重值都降为0,可能无法找到足够的近似商品来提供预测数据。

高斯函数

当距离为1时,高斯函数计算的权重为1;权重值随着距离增加而减小,但始终不会减小到0。这就避免了减法函数中那样出现无法预测的问题。
将高斯函数的实现方法Gaussian加入NumPredict中:

public double Gaussian(double dist, double sigma = 10)
{
    var exp = (double)Math.Pow(Math.E, -dist * dist / (2 * sigma * sigma));
    return exp;
}

通过代码也可以看出,高斯函数的速度不像之前的方法那样快。

在实现加权kNN前先来测试下这些权值计算函数:

var numPredict = new NumPredict();
var weight = numPredict.SubtractWeight(0.1f);
Console.WriteLine(weight);
weight = numPredict.InverseWeight(0.1f);
Console.WriteLine(weight);
weight = numPredict.Gaussian(0.1f);
Console.WriteLine(weight);
weight = numPredict.Gaussian(1);
Console.WriteLine(weight);
weight = numPredict.SubtractWeight(1);
Console.WriteLine(weight);
weight = numPredict.InverseWeight(1);
Console.WriteLine(weight);
weight = numPredict.Gaussian(3);
Console.WriteLine(weight);

三个函数都符合距离越远,权重越小这一要求。

加权kNN

加权kNN与之前的普通kNN就在于对于最类似的k个商品的价格是求加权评价,而非普通的求平均。
加权平均的做法就是把每个商品的价格乘以其权重,累加所有加权价格后再除以权重的和。
NumPredict中加入加权kNN计算方法Weightedknn

public double Weightedknn(List<PriceStructure> data, double[] vec1, int k = 5,
    Func<double, double, double> weightf = null)
{
    if (weightf == null) weightf = Gaussian;
    //得到经过排序的距离值
    var dlist = GetDistances(data, vec1);
    var avg = 0d;
    var totalweight = 0d;

    //得到加权平均
    foreach (var kvp in dlist.Take(k))
    {
        var dist = kvp.Key;
        var idx = kvp.Value;
        var weight = weightf(dist, 10);

        avg += weight * data[idx].Result;
        totalweight += weight;
    }
    if (totalweight == 0) return 0;
    avg /= totalweight;
    return avg;
}

我们用下面的代码测试下加权kNN计算的结果:

var numPredict = new NumPredict();
var data = numPredict.WineSet1();
var price = numPredict.Weightedknn(data, new []{99d, 5});
Console.WriteLine(price);

可以看到WeightedknnKnnEstimate有更好的预测效果。
下面会介绍怎么去验证这些不同预测方法的优劣。

交叉验证

交叉验证是将数据集拆分为训练集和测试集。使用训练集来训练算法,并将测试集的每一项传入算法得到一个预测结果,将这个预测结果与真实值进行对比,最后得到一个误差分值。通过这个分值可以评估预测的准确程度。
通常交叉验证会进行多测,每次使用不同的数据集划分方式,结果分值也是几次交叉验证分值的平均值。在划分上一般训练集数据占数据集的95%,剩下的是测试集。
首先在NumPredict中实现数据划分方法DivideData

public Tuple<List<PriceStructure>, List<PriceStructure>> DivideData(List<PriceStructure> data,
    double test = 0.05f)
{
    var trainSet = new List<PriceStructure>();
    var testSet = new List<PriceStructure>();
    var rnd = new Random();
    foreach (var row in data)
    {
        if (rnd.NextDouble() < test)
            testSet.Add(row);
        else
            trainSet.Add(row);
    }
    return Tuple.Create(trainSet, testSet);
}

接着是一次测试的方法TestAlgorithm,仍然是放在NumPredict中:

public double TestAlgorithm(Func<List<PriceStructure>, double[], double> algf,
    List<PriceStructure> trainSet, List<PriceStructure> testSet)
{
    var error = 0d;
    foreach (var row in testSet)
    {
        var guess = algf(trainSet, row.Input);
        error += Math.Pow(row.Result - guess, 2);
    }
    return error / testSet.Count;
}

这个方法使用训练集训练,并在测试集上进行测试,使用预测结果与真实结果进行比较。我们在计算差异值时选择了平方差。方差给倾向于给所有测结果和真实结果都比较接近的算法更高的分值。如果不在意个别预测结果与真实值有较大起伏,可以使用差值绝对值的平均值代替方差。

最后是交叉测试的主方法CrossValidate,这个方法实际上就是将上面两个方法反复调用多并给出结果的平均值。

public double CrossValidate(Func<List<PriceStructure>, double[], double> algf,
    List<PriceStructure> data, int trials = 100, double test = 0.05f)
{
    var error = 0d;
    for (int i = 0; i < trials; i++)
    {
        var setDiv = DivideData(data, test);
        error += TestAlgorithm(algf, setDiv.Item1, setDiv.Item2);
    }
    return error / trials;
}

有了这些方法就可以测试不同的算法,或者是同一个算法给予不同的参数的预测效果。

var numPredict = new NumPredict();
var data = numPredict.WineSet1();
Func<List<NumPredict.PriceStructure>, double[], double> knnEstiDefault =
    (dataset, vec) => numPredict.KnnEstimate(dataset, vec);
var score = numPredict.CrossValidate(knnEstiDefault, data);
Console.WriteLine(score);
Func<List<NumPredict.PriceStructure>, double[], double> knnEsti3 =
    (dataset, vec) => numPredict.KnnEstimate(dataset, vec, 3);
score = numPredict.CrossValidate(knnEsti3, data);
Console.WriteLine(score);
Func<List<NumPredict.PriceStructure>, double[], double> knnEsti1 =
    (dataset, vec) => numPredict.KnnEstimate(dataset, vec, 1);
score = numPredict.CrossValidate(knnEsti1, data);
Console.WriteLine(score);

在博主的测试中,默认参数的kNN算法效果最好(分值最低,表示预测和实际误差最小)。
也可以试试加权kNN,以及使用非默认权重函数(高斯函数)的加权kNN。

var numPredict = new NumPredict();
var data = numPredict.WineSet1();
Func<List<NumPredict.PriceStructure>, double[], double> weightedKnnDefault =
    (dataset, vec) => numPredict.Weightedknn(dataset, vec);
var score = numPredict.CrossValidate(weightedKnnDefault, data);
Console.WriteLine(score);
Func<double, double, double> inverseWeight = (d, n) => numPredict.InverseWeight(d);
Func<List<NumPredict.PriceStructure>, double[], double> weightedKnnInverse =
    (dataset, vec) => numPredict.Weightedknn(dataset, vec, 5, inverseWeight);
score = numPredict.CrossValidate(weightedKnnInverse, data);
Console.WriteLine(score);

上面的测试数据集只有两个不同的选项 - 等级和年份。当商品有更多选项时,在比较相似度时怎样自动给予不同的选项不同的权重是下一部分要讨论的话题。

多种输入变量

之前的例子中我们的输入变量只有两种,而且这两种变量是经过特意设计。显示中输入可能有多种变量,而且这些变量可能有以下问题:

  1. 不同变量的值域差异较大,这会导致临近距离值不能真实反应两条记录的差异,即值域较大的变量所带来的影响会影响其他变量,即使这个变量本身不是与结果关系最大的变量。
  2. 有些变量与结果几乎没有关系,但之前的方法仍然会将其影响计算在内。

新的数据集

我们实现一个名为WineSet2的方法生成一个存在上文描述问题的输入集。

public List<PriceStructure> WineSet2()
{
    var rows = new List<PriceStructure>(300);
    var rnd = new Random();
    for (int i = 0; i < 300; i++)
    {
        //随机生成年代和等级
        var rating = rnd.NextDouble() * 50 + 50;
        var age = rnd.NextDouble() * 50;
        var aisle = (double)rnd.Next(1, 20);
        var sizeArr = new[] { 375d, 750d, 1500d, 3000d };
        var bottleSize = sizeArr[rnd.Next(0, 3)];
        //得到参考价格
        var price = WinePrice(rating, age);
        price *= (bottleSize / 750);
        //增加“噪声”
        price *= (rnd.NextDouble() * 0.9d + 0.2d); //配书代码的实现
        //加入数据集
        rows.Add(new PriceStructure()
        {
            Input = new[] { rating, age, aisle, bottleSize },
            Result = price
        });
    }
    return rows;
}

新的数据集添加了两个列,其中第三列模拟葡萄酒桶存放的通道。第四列模拟葡萄酒桶的尺寸。
试试生成一个新的数据集:

var numPredict = new NumPredict();
var data = numPredict.WineSet2();
Console.WriteLine(JsonConvert.SerializeObject(data));

在新的数据集上测试下之前的算法:

var numPredict = new NumPredict();
var data = numPredict.WineSet2();
Func<List<NumPredict.PriceStructure>, double[], double> knnEsti3 =
    (dataset, vec) => numPredict.KnnEstimate(dataset, vec, 3);
var score = numPredict.CrossValidate(knnEsti3, data);
Console.WriteLine(score);
Func<List<NumPredict.PriceStructure>, double[], double> weightedKnnDefault =
    (dataset, vec) => numPredict.Weightedknn(dataset, vec);
score = numPredict.CrossValidate(weightedKnnDefault, data);
Console.WriteLine(score);

通过结果可以看出交叉验证结果很不理想,这是因为我们没有对不同的变量区别对待。

按比例缩放

结果变量值域不一致的简单做法就是对输入值进行缩放,即类似归一化的操作。具体到实现上就是将变量乘以一个缩放比例。
下面的ReScale方法对每个列的变量进行了缩放:

public List<PriceStructure> ReScale(List<PriceStructure> data, double[] scale)
{
    return (from row in data
            let scaled = scale.Select((s, i) => s * row.Input[i]).ToArray()
            select new PriceStructure()
            {
                Input = scaled,
                Result = row.Result
            }).ToList();
}

数组参数scale保存了每个列的缩放比例。
我们可以试着构造一个缩放比例,如过道信息与价格无关,将其缩放比例设为0。

var numPredict = new NumPredict();
var data = numPredict.WineSet2();
var sdata = numPredict.ReScale(data, new[] { 10, 10, 0, 0.5 });
Func<List<NumPredict.PriceStructure>, double[], double> knnEsti3 =
    (dataset, vec) => numPredict.KnnEstimate(dataset, vec, 3);
var score = numPredict.CrossValidate(knnEsti3, sdata);
Console.WriteLine(score);
Func<List<NumPredict.PriceStructure>, double[], double> weightedKnnDefault =
    (dataset, vec) => numPredict.Weightedknn(dataset, vec);
score = numPredict.CrossValidate(weightedKnnDefault, sdata);
Console.WriteLine(score);

优化缩放比例

上面的代码中,由于输入集是我们子集构造的,所以知道如何选择最佳的缩放比例。现实中确定缩放比例就需要其他技巧。
利用之前文章介绍的优化算法可以帮我们选择最佳的缩放比例。
优化算法需要一个值域范围,即每个例的缩放比例的范围以及一个成本函数。
对于值域范围,如下代码生成的结果就很适合比例:

public List<Tuple<int, int>> GetWeightDomain(int count)
{
    var domains = new List<Tuple<int, int>>(count);
    for (var i = 0; i < 4; i++)
    {
        domains.Add(Tuple.Create(0, 20));
    }
    return domains;
}

权重最小值0表示此项与结果毫无关系。

对于成本函数,我们可以用如下方法包装下之前实现的CrossValidate就能快速得到一个非常好用的成本函数。

public Func<double[], double> CreateCostFunction(Func<List<PriceStructure>, double[], double> algf,
    List<PriceStructure> data)
{
    Func<double[], double> Costf = scale =>
    {
        var sdata = ReScale(data, scale);
        return CrossValidate(algf, sdata, 10);
    };
    return Costf;
}

另外我们还需要之前文章实现的优化算法,由于参数类型的问题,我们不能直接使用之前的代码,而需要对参数类型稍作调整:

public List<double> AnnealingOptimize(List<Tuple<int, int>> domain, Func<double[], double> costf,
    float T = 10000.0f, float cool = 0.95f, int step = 1)
{
    //随机初始化值
    var random = new Random();
    var vec = domain.Select(t => (double)random.Next(t.Item1, t.Item2)).ToArray();

    while (T > 0.1)
    {
        //选择一个索引值
        var i = random.Next(0, domain.Count - 1);
        //选择一个改变索引值的方向
        var dir = random.Next(-step, step);
        //创建一个代表题解的新列表,改变其中一个值
        var vecb = vec.ToArray();
        vecb[i] += dir;
        if (vecb[i] < domain[i].Item1) vecb[i] = domain[i].Item1;
        else if (vecb[i] > domain[i].Item2) vecb[i] = domain[i].Item2;
        //计算当前成本和新成本
        var ea = costf(vec);
        var eb = costf(vecb);
        //是更好的解?或是退火过程中可能的波动的临界值上限?
        if (eb < ea || random.NextDouble() < Math.Pow(Math.E, -(eb - ea) / T))
            vec = vecb;
        //降低温度
        T *= cool;
    }
    return vec.ToList();
}

public List<double> GeneticOptimize(List<Tuple<int, int>> domain, Func<double[], double> costf,
    int popsize = 50, int step = 1, float mutprob = 0.2f, float elite = 0.2f, int maxiter = 100)
{
    var random = new Random();
    //变异操作
    Func<double[], double[]> mutate = vec =>
    {
        var i = random.Next(0, domain.Count - 1);
        if (random.NextDouble() < 0.5 && vec[i] > domain[i].Item1)
            return vec.Take(i).Concat(new[] { vec[i] - step }).Concat(vec.Skip(i + 1)).ToArray();
        else if (vec[i] < domain[i].Item2)
            return vec.Take(i).Concat(new[] { vec[i] + step }).Concat(vec.Skip(i + 1)).ToArray();
        return vec;
    };
    //配对操作
    Func<double[], double[], double[]> crossover = (r1, r2) =>
    {
        var i = random.Next(1, domain.Count - 2);
        return r1.Take(i).Concat(r2.Skip(i)).ToArray();
    };
    //构造初始种群
    var pop = new List<double[]>();
    for (int i = 0; i < popsize; i++)
    {
        var vec = domain.Select(t => (double)random.Next(t.Item1, t.Item2)).ToArray();
        pop.Add(vec);
    }
    //每一代中有多少胜出者?
    var topelite = (int) (elite*popsize);
    Func<double, double, int> cf = (x, y) => x == y ? 1 : x.CompareTo(y);
    var scores = new SortedList<double, double[]>(cf.AsComparer());
    //主循环
    for (int i = 0; i < maxiter; i++)
    {
        foreach (var v in pop)
           scores.Add(costf(v),v);
        var ranked = scores.Values;
        //从胜出者开始
        pop = ranked.Take(topelite).ToList();

        //添加变异和配对后的胜出者
        while (pop.Count<popsize)
        {
            if (random.NextDouble() < mutprob)
            {
                //变异
                var c = random.Next(0, topelite);
                pop.Add(mutate(ranked[c]));
            }
            else
            {
                //配对
                var c1 = random.Next(0, topelite);
                var c2 = random.Next(0, topelite);
                pop.Add(crossover(ranked[c1],ranked[c2]));
            }
        }

        //打印当前最优值
        //Console.WriteLine(scores.First().Key);
    }
    return scores.First().Value.ToList();
}

好了,现在可以试着用优化算法得到缩放比例了:
首先来试一下模拟退火算法:

var numPredict = new NumPredict();
var data = numPredict.WineSet2();
Func<List<NumPredict.PriceStructure>, double[], double> knnEstimate =
    (dataset, vec) => numPredict.KnnEstimate(dataset, vec);
var costf = numPredict.CreateCostFunction(knnEstimate, data);
var optimization = new Travel();
var optDomain = optimization.AnnealingOptimize(numPredict.GetWeightDomain(4), costf, step: 2);
Console.WriteLine(JsonConvert.SerializeObject(optDomain));

接着可以试试速度慢但效果更好的遗传算法:

var numPredict = new NumPredict();
var data = numPredict.WineSet2();
Func<List<NumPredict.PriceStructure>, double[], double> knnEstimate =
    (dataset, vec) => numPredict.KnnEstimate(dataset, vec);
var costf = numPredict.CreateCostFunction(knnEstimate, data);
var optimization = new Travel();
var optDomain = optimization.GeneticOptimize(numPredict.GetWeightDomain(4), costf, popsize: 5);
Console.WriteLine(JsonConvert.SerializeObject(optDomain));

这种自动确定缩放比例的方法也可以让我们了解到哪些因素与结果关系密切,从而采取更好的市场策略。

不对称分布

在之前的例子中,预测价格通过取相似商品价格的平均值或加权平均值来进行。但是对于如下假设这种处理方法就会有问题。
假设一部分葡萄酒是从折扣店购买,其价格只有正常价格的50%,但是这个折扣没有作为变量出现在输入数据中。
我们用方法WineSet3模拟这样一个数据集:

public List<PriceStructure> WineSet3()
{
    var rows = WineSet1();
    var rnd = new Random();
    foreach (var row in rows)
    {
        if (rnd.NextDouble() < 0.5)
            // 模拟从折扣店购得的葡萄酒
            row.Result *= 0.5;
    }
    return rows;
}

这个数据集是在WineSet1生成的数据基础上改造而来。
我们仍然可以用之前的算法处理这个数据集:

var numPredict = new NumPredict();
var data = numPredict.WineSet3();
var price = numPredict.WinePrice(99, 20);
Console.WriteLine(price);
price = numPredict.Weightedknn(data, new[] { 99d, 20 });
Console.WriteLine(price);

可以看到真实价格和预测价格之间可能有较大差异。而且由于平均我们平均处理预测价格可能被进行了25%的折扣。

估计密度概率

我们需要实现一个方法来确定价格位于一个区间的概率。我们首先找出一定数量的相似商品,然后用价格处于区间内的相似商品的权重和处于所有相似商品的权重和得到商品处于这个价格区间的概率。
我们在ProbGuess方法中实现这个概率估计过程:

public double ProbGuess(List<PriceStructure> data, double[] vec1, double low,
    double high, int k = 5, Func<double, double, double> weightf = null)
{
    if (weightf == null) weightf = Gaussian;
    var dlist = GetDistances(data, vec1);
    var nweight = 0d;
    var tweight = 0d;
    for (int i = 0; i < k; i++)
    {
        var dlistCurr = dlist.Skip(i).First();
        var dist = dlistCurr.Key;
        var idx = dlistCurr.Value;
        var weight = weightf(dist, 10);
        var v = data[idx].Result;
        // 当前数据点在指定范围吗?
        if (v >= low && v <= high)
            nweight += weight;
        tweight += weight;
    }
    if (tweight == 0) return 0;
    //概率等于位于指定范围内的权重值除以所有权重值
    return nweight / tweight;
}

参数low和high就是要判断的价格区间。尝试下这个概率预测函数:

var numPredict = new NumPredict();
var data = numPredict.WineSet3();
var prob = numPredict.ProbGuess(data, new[] { 99d, 20 }, 40, 80);
Console.WriteLine(prob);
prob = numPredict.ProbGuess(data, new[] { 99d, 20 }, 80, 120);
Console.WriteLine(prob);
prob = numPredict.ProbGuess(data, new[] { 99d, 20 }, 120, 1000);
Console.WriteLine(prob);
prob = numPredict.ProbGuess(data, new[] { 99d, 20 }, 30, 120);
Console.WriteLine(prob);

通过一小段一小段的传入价格区间进行概率计算,可以确定整个数据集的价格分布情况。
下节将通过一种直观的方法来展示概率分布情况:

绘制概率分布

通过绘制概率分布图,可以避免上节逐段猜测价格区间的做法。

关于C#函数图的绘制,经过一番寻找在GitHub发现了这个名为MatplotlibCS的项目。
MatplotlibCS采用了一种很特殊的方式对matplotlib进行封装,关于这个项目的起源,可以看这篇博文

这里简单介绍下MatplotlibCS的工作方式,MatplotlibCS的代码分两部分,C#编写的客户端,以及Python编写的服务器端。C#端将Python库matplotlib所需要的数据通过http传输到基于Python库Flask编写的服务器端。Flask接收客户端传来的数据并在内部调用matplotlib完成实际的坐标图像绘制。
这种方式可以扩展到其他有C#调用Python库需求的常见。

下面来说说博主配置MatplotlibCS的曲折经历。博主电脑上安装有官方2.7.4版本的Python,于是直接使用如下命令安装matplotlib:

python -m pip install matplotlib

当然这会毫无意外的报错(博主我也是折腾到后来才知道),基本上常见的错误如:

The following required packages can not be built:
freetype, png

去matplotlib官网看看,文档中说Windows平台建议使用如WinPython等第三方发行版,这些版本中一般都集成了matplotlib及一些常用的库。于是下载了一个基于3.6.0的64位WinPython。
试了下集成的matplotlib可以使用。开始进行下一步。

可以使用下面的代码测试matplotlib是否可用:
from pylab import *
a=[1,2,3,4]
b=[2,3,4,1]
plot(a,b)
show()

MatplotlibCS的服务器端部分,使用

python.exe matplotlib_cs.py

这样的格式来启动基于Python的Web服务。其中,文件matplotlib_cs.py位于MatplotlibCS-master/MatplotlibCS/Python内。
运行上述命令,报错说找不到名为task的库。使用pip安装后,继续报错无法由task导入Task。很是无解,百思不得姐后,放弃这种方法。

就在这山穷水尽之时,灵机一动想到了WSL(好多地方直接称其Windows Bash)。既然是Python部分只是作为一个服务端,我们完全可以把其独立运行。而WSL就是一个很好的运行环境。

MatplotlibCS的代码也做了判断,如果检测到服务端在运行(不管是何种方式启动的),C#代码就不会再去启动Python服务端了。
使用WSL的一个非常妙的地方时,WSL中发布的Python服务也是在127.0.0.1,也就是本机,这个域名下。由于这个服务端地址在MatplotlibCS代码中是写死的,使用WSL就不需要我们重新修改、编译MatplotlibCS代码。而是可以直接使用MatplotlibCS的Nuget包。
使用Docker for Windows可以实现和WSL一致的效果,它们两个也是各有所长。

WSL中有2.7.6和3.4.3两个版本的Python,命令分别为python和python3,对应的pip分别为pip和pip3。博主使用Python3运行MatplotlibCS的Python服务成功。下面是步骤:
在WSL中使用pip3安装matplotlib

sudo pip3 install matplotlib #注意需要root权限

一般来说会报如下错误:

The following required packages can not be built:
freetype, png

需要在WSL单独安装这个包:

sudo apt-get install libfreetype6-dev

这个包及其依赖包可以满足安装matplotlib的需要。安装完成后,重试matplotlib安装,一般都会成功。

顺便安装后面需要用到的flask

sudo pip3 install flask

环境装好,下面就可以启动服务了。
首先进入MatplotlibCS源码中包含matplotlib_cs.py这个文件的目录。执行:

python3 matplotlib_cs.py

不出意外服务可以正常启动。可以看到如下提示:

Running on http://127.0.0.1:57123/ (Press CTRL+C to quit)

不得不说,可以和Windows共用同一套文件是WSL最大特色。如果使用Docker for Windows免不了要挂载主机目录。

服务端完成,开始编写客户端代码,首先在项目中安装MatplotlibCS
可以直接使用Nuget安装:

Install-Package MatplotlibCS

还需要自行安装下NLog(MatplotlibCS使用了NLog但没有在Nuget包中声明这个依赖)

Install-Package NLog 

安装后,我们写一段简单的代码进行测试(这个代码绘制的图像,和之前测试matplotlib的Python代码是相同的):

public List<Axes> BuildAxes()
{
    return new List<Axes>()
    {
        new Axes(1, "X", "Y")
        {
            Title = "MatplotlibCS Test",
            PlotItems =
            {
                new Line2D("Line 1")
                {
                    X = new List<object>() {1,2,3,4},
                    Y = new List<double>() {2,3,4,1}
                }
            }
        }
    };
}

public void Draw(List<Axes> plots)
{
    // 由于我们在外部启动Python服务,这两个参数传空字符串就可以了
    var matplotlibCs = new MatplotlibCS.MatplotlibCS("", "");

    var figure = new Figure(1, 1)
    {
        FileName = $"/mnt/e/Temp/result{DateTime.Now:ddHHmmss}.png",
        OnlySaveImage = true,
        DPI = 150,
        Subplots = plots
    };
    var t = matplotlibCs.BuildFigure(figure);
    t.Wait();
}

这其中BuildAxes用于构造要绘制的坐标数据,Draw用于实际完成调用来生成图片。
使用下面的代码测试:

var numPredict = new NumPredict();
var axes = numPredict.BuildAxes();
numPredict.Draw(axes);

执行成功后就会在FileName指定的目录种看到图片。
注意这个图片输出目录,我们写的是一个WSL样式的绝对路径(代码种表示E盘下的Temp目录)。这样可以让WSL中的Python服务直接把输出的文件写到本地磁盘中。而如果使用相对路径,客户端会把其变成基于Windows文件系统的绝对路径并传给WSL,而WSL无法识别这种路径,导致Python服务报错。

书归正题,我们开始实现绘制本例的概率分布图,我们将绘制两种不同类型的概率分布图:
第一种称为累计概率,累计图显示的是价格小于给定值的概率,所以随着给定价格(x轴)的增加,概率值(y轴)会逐渐升高直到1。
绘制累计概率图的方法很简单,只需要逐渐增大价格区段并循环调用ProbGuess方法,将得到概率值作为Y轴值即可,具体实现见CumulativeGraph

public void CumulativeGraph(List<PriceStructure> data, double[] vec1,
    double high, int k = 5, Func<double, double, double> weightf = null)
{
    if (weightf == null) weightf = Gaussian;
    var t1 = new List<object>();
    for (var i = 0d; i < high; i += 0.1)
        t1.Add(i);
    var cprob = t1.Select(v => ProbGuess(data, vec1, 0, (double)v, k, weightf)).ToList();

    var axes = new Axes(1, "Price", "Cumulative Probility")
    {
        Title = "Price Cumulative Probility",
        PlotItems =
        {
            new Line2D("")
            {
                X = t1,
                Y = cprob
            }
        }
    };
    Draw(new List<Axes>() { axes });
}

调用这个方法也很简单:

生成概率累加图如下:

可以看到在20-40及60-80区间概率和发生了变动,可以确定这两个价格区间就是商品价格的主要分布区间,这样就避免了之前的无绪猜测。
另一种概率图绘制方法就是绘制该价格位置处的实际概率。但如果直接绘制,所显示图像将是一个个跳跃的小点。所以我们采取将一个价格对应的概率与左右的概率进行加权平均,这样可以使绘制的函数图像更连续。
我们在ProbabilityGraph中实现了概率值的加权平均处理及函数图的绘制:

public void ProbabilityGraph(List<PriceStructure> data, double[] vec1,
    double high, int k = 5, Func<double, double, double> weightf = null,double ss=5)
{
    if (weightf == null) weightf = Gaussian;
    // 价格值域范围
    var t1 = new List<object>();
    for (var i = 0d; i < high; i += 0.1)
        t1.Add(i);
    // 整个值域范围的所有概率
    var probs = t1.Cast<double>()
        .Select(v => ProbGuess(data, vec1, v, v+0.1, k, weightf)).ToList();
    // 通过加上近邻概率的高斯计算结果,对概率值做平滑处理
    var smoothed = new List<double>();
    for (int i = 0; i < probs.Count; i++)
    {
        var sv = 0d;
        for (int j = 0; j < probs.Count; j++)
        {
            var dist = Math.Abs(i - j)*0.1;
            var weight = Gaussian(dist, sigma: ss);
            sv += weight*probs[j];
        }
        smoothed.Add(sv);
    }
    var axes = new Axes(1, "Price", "Probility")
    {
        Title = "Price Probility",
        PlotItems =
        {
            new Line2D("")
            {
                X = t1,
                Y = smoothed
            }
        }
    };
    Draw(new List<Axes>() { axes });
}

开始绘图吧

var numPredict = new NumPredict();
var data = numPredict.WineSet3();
numPredict.ProbabilityGraph(data, new[] { 1d, 1 }, 120);

生成的图像如下:

可以看到商品所处的价格区间和概率累计图所反映的价格区间一致,但是这个图像还能更直观的反应出落在哪个价格区间的商品更多。
通过商品价格概率分布图,还能看出我们的输入数据缺少了一定的关键因素。所以这样的图可以给决策者提供更好的销售策略,定价策略支持。

总结

kNN算法的缺点在于要计算每个点之间的相似度(距离),计算量很大。另外如果需要通过优化算法确定输入列的权重,又会增加很大的计算量。在数据集很大时,计算将会非常缓慢。
而kNN的优点时可以增量的进行训练。通过绘制概率分布,还可以看出输入中是否缺乏的必要的因素。

posted @ 2017-03-04 10:18  hystar  阅读(932)  评论(0编辑  收藏  举报