本节书摘来异步社区《机器学习项目开发实战》一书中的第1章,第1.3节,作者:【美】Mathias Brandewinder(马蒂亚斯·布兰德温德尔),更多章节内容可以访问云栖社区“异步社区”公众号查看
1.3 我们的第一个模型(C#版本)
我们从C#实现开始热身(这应该是读者熟悉的领域),并在Visual Studio中创建一个C#控制台应用程序。我将自己的解决方案称作DigitsRecognizer(数字识别器),C#控制台应用名为CSharp——尽管取些比我更有创意的名称!
1.3.1 数据集组织
显然,首先我们需要数据。我们从http://1drv.ms/1sDThtz下载trainingsample.csv数据集,保存在机器上的某个位置。同一个位置还有第二个文件—— validationsample.csv,这个文件以后才会用到,但是我们现在就获取它。这两个文件采用CSV格式(逗号分隔值),结构如图1-5所示。第一行是文件头,后面的每行代表一个图像。第一列(“label”)表示图像代表的数字,接下来的784列(“pixel0”,“pixel11”)代表原始图像的每个像素,按照灰度编码(0~255,0表示纯黑,255表示纯白,中间的任何值是灰度级)。
例如,第一行的数据表示数字1,如果我们打算从这行数据重建真实的图像,可以将行分成28个“切片”,每个切片代表图像中的一行:像素(Pixel)0,像素1…像素27编码图像的第一行,像素28,像素29…像素55代表第二行,依次类推。我们最终一共有785列数据:一列用于标签,其他784列代表28行×28列=784个像素。图1-6所示用简化的4×4图像描述了编码机制:
真实的图像是1(第1列),然后是代表每个像素灰度的16列数据。
■ 注意:
如果认真观察,就会注意到trainingsample.csv文件只有5000行,而不是前面提到的50000行。创建较小的文件是为了方便,只保留了原始数据最开始的一部分。50000行这个数字并不算大,但是足以让我们的进展变得很慢,这令人不快,此时在较大的数据集上工作也没有什么价值。
1.3.2 读取数据
我们将用典型的C#风格,围绕表示问题领域的几个类和接口构造代码。我们将在Observation类中保存每个图像的数据,用IClassifier接口表示算法,这样就可以在以后创建模型的变种。
第一步,我们需要从CSV文件读取数据,放入一组观测值中。下面我们进入解决方案,并在CSharp控制台项目中添加一个类,以保存观测值。
程序清单1-1 在Observation类中保存数据
public class Observation
{
public Observation(string label, int[] pixels)
{
this.Label = label;
this.Pixels = pixels;
}
public string Label { get; private set; }
public int[] Pixels { get; private set; }
}```
接下来,我们添加一个DataReader类,用于从数据文件中读取观测值。这里实际上是执行两个不同的任务:从文本文件中提出每个相关的行,将每行转换为观测值类型。我们将这些任务分别放在两个方法中。
程序清单1-2 用DataReader类从文件读取
public class DataReader
{
private static Observation ObservationFactory(string data)
{
var commaSeparated = data.Split(',');
var label = commaSeparated[0];
var pixels =
commaSeparated
.Skip(1)
.Select(x => Convert.ToInt32(x))
.ToArray();
return new Observation(label, pixels);
}
public static Observation[] ReadObservations(string dataPath)
{
var data =
File.ReadAllLines(dataPath)
.Skip(1)
.Select(ObservationFactory)
.ToArray();
return data;
}
}`
注意,我们的代码主要是LINQ表达式!面向表达式的代码(如LINQ或者后面将会看到的F#)有助于编写非常清晰的代码,以直接的方式表达意图,通常比过程式编码有效得多。这种代码读起来更像自然语言:“读取所有行,跳过文件头,根据逗号拆分各行,解析为整数,为我提供新的观测值”。如果我和同行交谈,这就是描述意图的方法,而这个意图很清晰地反映在代码中。这种代码特别适合于数据操纵任务,因为它提供了描述数据转换工作流的自然手段,是机器学习的基础。毕竟,这就是LINQ的设计目的——“语言集成查询”!
我们已经有了数据、阅读器和保存它们的结构——下面在控制台应用中将其组合起来,用本地机器上实际数据文件的路径代替trainingPath中的PATH-ON-YOUR-MACHINE。
程序清单1-3 控制台应用程序
class Program
{
static void Main(string[] args)
{
var trainingPath = @"PATH-ON-YOUR-MACHINE\trainingsample.csv";
var training = DataReader.ReadObservations(trainingPath);
Console.ReadLine();
}
}```
如果你在代码块的最后放置一个断点,然后以调试模式运行,应该看到training变量是一个包含5000个观测值的数组。很好——一切似乎都很正常。
下一个任务是编写一个分类器,获得一个图像时,分类器将其与数据集中的每个Observation比较,找出最类似的,返回其标签。为此,需要两个元素:Distance(距离)和Classifier(分类器)。
####1.3.3 计算图像之间的距离
我们从距离开始,所需的是一个方法,用以取得两个像素数组并返回描述它们的差异的数字。距离是算法中的易变领域,我们很可能想要试验不同的图像比较方法,找出最合适的方法。因此采用一种设计,使我们能够轻松地替换不同的距离定义且不需要做很多的代码更改,是最为可取的。接口提供了一种方便的机制,可以使用它避免紧密耦合,确保在以后想要更改距离代码时,不需要遇到令人烦恼的重构问题。所以,我们从一开始就提取一个接口。
程序清单1-4 IDistance接口
public interface IDistance
{
double Between(int[] pixels1, int[] pixels2);
}`
有了接口之后,需要一个实现。同样,我们现在将采用可能有效的最简单的方法。例如,如果我们想要的是计量两个图像的差异,为什么不逐个像素比较,计算每个像素的差值,然后加总其绝对值?完全相同的图像距离为0,两个像素相差越远,两个图像的距离就越大。这种距离的名称为“曼哈顿距离”,实现起来相当简单,如程序清单1-5所示。
程序清单1-5 计算图像之间的曼哈顿距离
public class ManhattanDistance : IDistance
{
public double Between(int[] pixels1, int[] pixels2)
{
if (pixels1.Length != pixels2.Length)
{
throw new ArgumentException("Inconsistent image sizes.");
}
var length = pixels1.Length;
var distance = 0;
for (int i = 0; i < length; i++)
{
distance += Math.Abs(pixels1[i] - pixels2[i]);
}
return distance;
}
}```
有趣的事实:曼哈顿距离
前面我已经提到过,距离可以用多种方法计算。我们在这里使用的具体公式被称作“曼哈顿距离”。这个名称来自于这样一个事实:如果你是纽约市的出租车司机,这就是计算两点之间驾车距离的方法。因为所有街道都被组织为一个完善的矩形网格,可以计算东/西位置和南/北位置之间的绝对距离,这就是我们的代码中所完成的工作。这种方法还有一种不那么有诗意的名称——L1距离。
我们取两个图像并逐个像素比较,计算差值并返回总数,代表两个图像的距离。注意,这里使用的代码采用了过程式风格,完全没有使用LINQ。实际上,我最初使用LINQ编写了这一段代码,但是老实说不喜欢结果的显示方式。在我看来,在某种程度(或者对于某些运算来说)上,以C#语言编写的LINQ代码看起来有点过于复杂,这主要是因为C#很冗长,尤其是其函数构造(Func<A,B,C>)。这也是比较两种风格的有趣示例。在此,要理解代码所做的是什么,需要逐行阅读并将其翻译为“人类的描述”。这段代码还使用了突变(Mutation)——这是一种需要注意的风格。
MATH.ABS( )
你可能觉得奇怪,为什么我们要使用绝对值?为什么不简单地计算差值?为了理解这成为问题的原因,考虑如下的例子:
<div style="text-align: center"><img src="https://yqfile.alicdn.com/7f59e33503eb3fce6e1fd3bf6ab20fac4a6377c9.png" width="250" height="">
</div>
如果我们使用像素颜色的“简单”差值,就会遇到一个微妙的问题。计算第一个和第二个图像的差值将得到−255+255−255+255=0,和第一个图像与自身的距离相同。这明显是不正确的。第一个图像明显和自身完全相同,而按照该指标,不同的图像1和2在外观上可能是相同的!使用绝对值的理由就是:如果不这么做,方向相反的差异就会相互抵消,结果是完全不同的图像可能会产生很高的相似度。绝对值保证不会有这种问题:任何差异都根据其量级进行处置,而不考虑其符号。
####1.3.4 编写分类器
我们已经有了比较图像的方法,现在可以从一个通用接口开始,编写分类器了。在每种情况下,我们都预期使用一个两步的过程。提供一个已知观测值的训练集对分类器进行训练,一旦训练完成,我们就应该能够预测某个图像的标签了,见程序清单1-6。
程序清单1-6 IClassifier接口
public interface IClassifier
{
void Train(IEnumerable<Observation> trainingSet);
string Predict(int[] pixels);
}`
下面是实现前述算法的多种方法之一。
程序清单1-7 基本分类器实现
public class BasicClassifier : IClassifier
{
private IEnumerable<Observation> data;
private readonly IDistance distance;
public BasicClassifier(IDistance distance)
{
this.distance = distance;
}
public void Train(IEnumerable<Observation> trainingSet)
{
this.data = trainingSet;
}
public string Predict(int[] pixels)
{
Observation currentBest = null;
var shortest = Double.MaxValue;
foreach (Observation obs in this.data)
{
var dist = this.distance.Between(obs.Pixels, pixels);
if (dist < shortest)
{
shortest = dist;
currentBest = obs;
}
}
return currentBest.Label;
}
}```