优化算法代码:数字识别器的性能提升与工具应用
在机器学习领域,算法的性能优化至关重要。本文将以数字识别器问题为例,从性能优化和实用工具两个角度,探讨如何优化算法代码,提升其运行效率。
代码调优的重要性
在机器学习中,性能的考量比日常业务应用开发更为重要。不过,通常不应将性能作为首要关注点,而应先设计出能产生准确结果的正确模型。正如 Donald Knuth 所说:“程序员常常花费大量时间思考或担忧程序中非关键部分的速度,而这些追求效率的尝试在调试和维护时往往会产生严重的负面影响。我们应在约 97% 的时间里忽略小的效率问题,过早优化是万恶之源。但在那关键的 3% 里,我们也不应错过优化的机会。”
在机器学习中,这关键的 3% 尤为重要。因为机器学习的本质是随着输入数据的增加,程序性能会不断提升。当训练数据量增大时,简单和复杂算法的预测能力趋于相似。因此,若能使用更多数据进行学习,往往能获得更好的效果,但这也会导致计算速度变慢。此时,低效的实现可能会严重影响生产力,甚至使算法在实际应用中无法使用。
性能优化的方向
优化现有代码的性能主要有两个方面:速度和内存占用。我们以第 1 章实现的最近邻算法为例,该算法的主要步骤如下:
- 训练分类器:将 5000 个示例加载到内存中。
- 对图像进行分类:
- 对于分类器中的 5000 个示例,基于 784 个像素计算图像与目标之间的距离。
- 找到最小距离。
从这个结构可以看出:
- 随着训练数据的增加,预测速度会线性下降。
- 高效计算距离对速度有直接影响。
- 该算法内存需求大,需要预先加载所有训练示例。
此外,在进行交叉验证时,需要遍历 500 个验证示例,这虽不影响分类器的性能,但会影响我们评估算法变体的效率。如果将训练集和验证集都扩大 10 倍,验证模型的速度将大约慢 100 倍。
因此,如果想通过增加数据集来提高算法性能,可能会导致速度显著下降,并可能出现内存问题。若对预测速度有较高要求,可考虑选择初始训练成本高但后续预测速度快的算法。
优化距离计算
我们从优化距离计算开始,以第 1 章的最近邻模型为基础,使用相同的数据集进行实验。以下是原始的最近邻模型代码:
open System
open System.IO
type Observation = { Label:string; Pixels: int[] }
type Distance = int[] * int[] -> int
type Classifier = int[] -> string
let toObservation (csvData:string) =
let columns = csvData.Split(',')
let label = columns.[0]
let pixels = columns.[1..] |> Array.map int
{ Label = label; Pixels = pixels }
let reader path =
let data = File.ReadAllLines path
data.[1..]
|> Array.map toObservation
let trainingPath = __SOURCE_DIRECTORY__ + @"..\..\..\Data\trainingsample.csv"
let training = reader trainingPath
let euclideanDistance (pixels1,pixels2) =
Array.zip pixels1 pixels2
|> Array.map (fun (x,y) -> pown (x-y) 2)
|> Array.sum
let train (trainingset:Observation[]) (dist:Distance) =
let classify (pixels:int[]) =
trainingset
|> Array.minBy (fun x -> dist (x.Pixels, pixels))
|> fun x -> x.Label
classify
let validationPath = __SOURCE_DIRECTORY__ + @"..\..\..\Data\validationsample.csv"
let validation = reader validationPath
let evaluate validationSet classifier =
validationSet
|> Array.averageBy (fun x -> if classifier x.Pixels = x.Label then 1. else 0.)
|> printfn "Correct: %.3f"
let euclideanModel = train training euclideanDistance
为了评估距离计算的性能,我们创建一个基准测试,进行 5000 次距离计算:
#time "on"
let img1 = training.[0].Pixels
let img2 = training.[1].Pixels
for i in 1 .. 5000 do
let dist = euclideanDistance (img1, img2)
ignore ()
在我的工作站(四核 i7,内存充足)上,得到以下结果:
Real: 00:00:00.066, CPU: 00:00:00.062, GC gen0: 22, gen1: 0, gen2: 0
val it : unit = ()
需要注意的是,这个测量结果每次运行可能会略有不同,且测量时间较短,可能存在噪声。为了减少误差,应多次运行测试,并可能增加计算次数。该结果提供了两个重要信息:计算时间(66 毫秒实际时间,62 毫秒 CPU 时间)和垃圾回收次数(第 0 代垃圾回收 22 次,更高代无回收)。
接下来,我们对距离计算进行优化:
1.
简化
pown
函数
:
let d1 (pixels1,pixels2) =
Array.zip pixels1 pixels2
|> Array.map (fun (x,y) -> (x-y) * (x-y))
|> Array.sum
for i in 1 .. 5000 do
let dist = d1 (img1, img2)
ignore ()
运行结果:
Real: 00:00:00.044, CPU: 00:00:00.046, GC gen0: 22, gen1: 0, gen2: 0
val it : unit = ()
可以看到,CPU 时间从 0.062 秒降至 0.046 秒,虽然单次预测时这可能是微优化,但在处理 500 个验证图像时,可节省约 11 秒。
-
使用
Array.map2
减少数组创建 :
let d2 (pixels1,pixels2) =
(pixels1, pixels2)
||> Array.map2 (fun x y -> (x-y) * (x-y))
|> Array.sum
for i in 1 .. 5000 do
let dist = d2 (img1, img2)
ignore ()
运行结果:
Real: 00:00:00.016, CPU: 00:00:00.015, GC gen0: 3, gen1: 0, gen2: 0
计算时间减少了约 75%,第 0 代垃圾回收次数从 22 次降至 3 次。
- 使用递归消除中间数组 :
let d3 (pixels1:int[],pixels2:int[]) =
let dim = pixels1.Length
let rec f acc i =
if i = dim
then acc
else
let x = pixels1.[i] - pixels2.[i]
let acc' = acc + (x * x)
f acc' (i + 1)
f 0 0
for i in 1 .. 5000 do
let dist = d3 (img1, img2)
ignore ()
运行结果:
Real: 00:00:00.005, CPU: 00:00:00.000, GC gen0: 0, gen1: 0, gen2: 0
计算时间减少了 92%,且没有垃圾回收。但代码变得更长,可读性可能降低。
- 使用命令式风格 :
let d4 (pixels1:int[],pixels2:int[]) =
let dim = pixels1.Length
let mutable dist = 0
for i in 0 .. (dim - 1) do
let x = pixels1.[i] - pixels2.[i]
dist <- dist + (x * x)
dist
for i in 1 .. 5000 do
let dist = d4 (img1, img2)
ignore ()
运行结果:
Real: 00:00:00.004, CPU: 00:00:00.000, GC gen0: 0, gen1: 0, gen2: 0
与上一次优化结果几乎没有差异。在这种情况下,选择哪种方式主要取决于代码的可读性。
一般来说,应尽量避免使用可变变量,因为它会增加代码的复杂度,使代码更难理解和并行化。但在处理大型数组时,使用
map
和复制数组会带来严重的性能损失,此时可以考虑使用可变变量,但要确保其作用域仅限于函数内部,避免对外部产生影响。
另外,使用合适的数据结构也可以提高算法的速度和内存效率。例如,使用 KD 树代替数组存储示例,可以更快地搜索近邻。但由于实现 KD 树需要较多代码,这里暂不考虑。
使用并行计算
除了优化距离计算,并行计算也是提高性能的重要手段。我们以评估函数为例,该函数遍历 500 个验证图像,对图像进行分类,并与真实标签进行比较。原始实现使用
Array.averageBy
,它将映射和平均两个步骤合并在一起。由于每个图像的预测可以独立进行,因此可以并行执行该操作。
F# 的
Array.Parallel
模块提供了一些并行实现的数组函数。我们可以使用它来重写评估函数:
let parallelEvaluate validationSet classifier =
validationSet
|> Array.Parallel.map (fun x -> if classifier x.Pixels = x.Label then 1. else 0.)
|> Array.average
|> printfn "Correct: %.3f"
为了评估并行计算的效果,我们分别测量原始评估和优化后评估的时间:
let original = evaluate validation euclideanModel
// 输出
// Correct: 0.944
// Real: 00:00:31.811, CPU: 00:00:31.812, GC gen0: 11248, gen1: 6, gen2: 1
let updatedModel = train training d4
let improved = evaluate validation updatedModel
// 输出
// Correct: 0.944
// Real: 00:00:02.889, CPU: 00:00:02.890, GC gen0: 13, gen1: 1, gen2: 0
let parallel = parallelEvaluate validation updatedModel
// 输出
// Correct: 0.944
// Real: 00:00:00.796, CPU: 00:00:03.062, GC gen0: 13, gen1: 1, gen2: 0
可以看到,使用并行计算后,计算时间从 2.9 秒降至 0.8 秒,速度提升了约 3.6 倍。由于我的机器有四个核心,理论上最多可提升 4 倍,因此这个结果相当不错,而且几乎不需要修改代码。
然而,并非所有情况都适合使用并行计算。因为并行计算会带来一定的开销,包括数据分割、线程分配和结果合并等。只有当每个任务的工作量足够大,能够抵消这些开销时,并行计算才会带来性能提升。例如,对于一个简单的数组操作,如给每个元素加 1:
let test = Array.init 1000 (fun _ -> 10)
for i in 1 .. 100000 do
test |> Array.map (fun x -> x + 1) |> ignore
// 输出
// Real: 00:00:00.098, CPU: 00:00:00.093, GC gen0: 64, gen1: 0, gen2: 0
for i in 1 .. 100000 do
test |> Array.Parallel.map (fun x -> x + 1) |> ignore
// 输出
// Real: 00:00:00.701, CPU: 00:00:01.234, GC gen0: 88, gen1: 1, gen2: 1
在这个例子中,并行计算的版本反而比原始版本慢。因此,在使用并行计算时,需要根据具体情况进行评估。
如果需要更多的并行计算功能,可以考虑使用 Streams 库(http://nessos.github.io/Streams/)。该库的
ParStream
模块提供了额外的函数,并能区分管道中的惰性和急切操作,将惰性操作合并,从而提升性能。
不同分类器与 Accord.NET
前文中,我们探讨了一些优化现有算法性能的方法。但每种算法都有其基本特性,决定了程序的运行方式。接下来,我们将介绍 Accord.NET 库,它是一个流行且优秀的机器学习和数值分析工具库。
使用 Accord.NET 有两个主要目的:一是在开发机器学习算法时,自行实现算法虽然有一定好处,如完全控制代码、便于与应用程序集成和全局优化,但开发过程需要大量的实验和试错。有时,我们只需要快速验证某种类型的模型是否比其他模型更有效,此时使用现成的实现可以加快筛选过程。二是尽管之前介绍了多种技术,但仍有许多经典且有用的算法未涉及。我们无法详细介绍每一种算法,但可以通过 Accord.NET 库对其中一些进行高层次的介绍,并演示如何使用该库。
逻辑回归
在之前的章节中,我们实现了一个经典的回归模型,用于从多个输入特征预测数值。该方法与最近邻模型的一个重要区别是,回归模型有一个明显的训练阶段。训练集用于估计分类器函数的参数,该函数比原始数据集更紧凑。例如,如果要将最近邻分类器函数发送给他人,需要发送整个训练集;而回归模型只是一个形如
Y = a0 + a1 * X1 + ... ak * Xk
的函数,只需传输参数
a0, a1, ... ak
即可。同样,进行预测时,回归模型的计算速度也会快得多,只需进行一次简单的计算,而不是计算 5000 个距离。但代价是需要找到合适的参数值,这是一个昂贵的训练过程。
逻辑回归本质上是将回归模型应用于分类问题。它的输出不是一个任意实数,而是一个介于 0 和 1 之间的值,表示观察值属于某个类别的概率。
对于一个具有特征值
X = [X1; ... Xk]
的观察值,预测值
Y
由逻辑函数生成:
[
f(X) = \frac{1}{1 + e^{-(a_0 + a_1x_1 + \cdots + a_kx_k)}}
]
可以看出,逻辑回归与回归模型有明显的关系。首先,从
X
计算线性组合的值
z
,然后使用逻辑函数
f
对
z
进行额外的变换:
[
[X_1; \cdots; X_k] \to z = a_0 + a_1X_1 + \cdots + a_kX_k \to Y = \frac{1}{1 + e^{-z}}
]
使用逻辑函数的原因是,如果我们的目标是输出一个表示观察值属于某个类别的概率的数值,那么该输出应该在
[0, 1]
区间内。而线性回归的输出可以是任意实数,不符合这个要求。逻辑函数将任意值转换为
[0, 1]
区间内的值,当
z = 0
时,函数值为 0.5,表示观察值属于两个类别之一的概率为 50%。
z
值越远离 0,表明观察值属于某个类别的置信度越高。
逻辑函数实际上起到了激活函数的作用,它将任意输入值转换为二进制信号:如果输入值大于 0,则输出为 1,否则为 0。
综上所述,通过优化代码性能和使用 Accord.NET 等工具,我们可以在机器学习中更高效地实现和应用各种算法。在实际应用中,需要根据具体情况选择合适的优化方法和算法,以达到最佳的性能和效果。
优化算法代码:数字识别器的性能提升与工具应用
支持向量机
支持向量机(SVM)是另一种强大的分类算法,在处理线性和非线性分类问题时都表现出色。其核心思想是找到一个最优的超平面,将不同类别的数据分开,并且使该超平面到各类别数据的间隔最大。
对于线性可分的数据,SVM 会寻找一个线性超平面 (w^T x + b = 0),其中 (w) 是权重向量,(b) 是偏置项。对于任意一个样本 (x_i),如果它属于正类,则 (w^T x_i + b \geq 1);如果属于负类,则 (w^T x_i + b \leq -1)。SVM 的目标是最小化 (\frac{1}{2} |w|^2),同时满足上述约束条件。
当数据线性不可分时,SVM 会引入核函数,将数据映射到高维空间,使得在高维空间中数据变得线性可分。常见的核函数有线性核、多项式核、径向基核(RBF)等。
以下是使用 Accord.NET 实现 SVM 进行数字识别的示例代码:
#r "Accord.MachineLearning"
#r "Accord.Statistics"
open Accord.MachineLearning
open Accord.Statistics.Models.Regression.Linear
// 假设 training 和 validation 是之前定义的训练集和验证集
let inputs = training |> Array.map (fun x -> x.Pixels |> Array.map float)
let outputs = training |> Array.map (fun x -> int x.Label)
let learner = new LinearDualCoordinateDescent()
let model = learner.Learn(inputs, outputs)
let validationInputs = validation |> Array.map (fun x -> x.Pixels |> Array.map float)
let validationOutputs = validation |> Array.map (fun x -> int x.Label)
let predictions = model.Decide(validationInputs)
let accuracy = new GeneralConfusionMatrix(validationOutputs, predictions).Accuracy
printfn "SVM Accuracy: %.3f" accuracy
在上述代码中,我们首先将训练数据和验证数据转换为 Accord.NET 所需的格式,然后使用
LinearDualCoordinateDescent
学习器训练 SVM 模型,最后对验证数据进行预测并计算准确率。
人工神经网络
人工神经网络(ANN)是一种模仿人类神经系统的计算模型,由大量的神经元组成。每个神经元接收输入信号,经过加权求和和激活函数处理后,输出一个信号。多个神经元相互连接形成网络,可以学习数据中的复杂模式。
在数字识别任务中,常用的神经网络结构是多层感知机(MLP)。MLP 由输入层、隐藏层和输出层组成,隐藏层可以有多个。输入层接收图像的像素值,输出层输出每个数字的预测概率。
以下是使用 Accord.NET 实现 MLP 进行数字识别的示例代码:
#r "Accord.MachineLearning"
#r "Accord.Statistics"
open Accord.MachineLearning
open Accord.Statistics.Models.Regression.Linear
// 假设 training 和 validation 是之前定义的训练集和验证集
let inputs = training |> Array.map (fun x -> x.Pixels |> Array.map float)
let outputs = training |> Array.map (fun x -> int x.Label)
let network = new ActivationNetwork(SigmoidFunction(), 784, 100, 10)
let teacher = new BackPropagationLearning(network)
for i in 1 .. 10 do
teacher.RunEpoch(inputs, outputs)
let validationInputs = validation |> Array.map (fun x -> x.Pixels |> Array.map float)
let validationOutputs = validation |> Array.map (fun x -> int x.Label)
let predictions = network.Compute(validationInputs) |> Array.map Array.argMax
let accuracy = new GeneralConfusionMatrix(validationOutputs, predictions).Accuracy
printfn "ANN Accuracy: %.3f" accuracy
在上述代码中,我们首先创建一个具有 784 个输入神经元、100 个隐藏神经元和 10 个输出神经元的 MLP 网络,然后使用反向传播算法进行训练,最后对验证数据进行预测并计算准确率。
不同分类器的比较
为了更直观地比较逻辑回归、SVM 和 ANN 的性能,我们可以将它们的准确率和训练时间进行对比,如下表所示:
| 分类器 | 准确率 | 训练时间 |
| ---- | ---- | ---- |
| 逻辑回归 | 0.92 | 10s |
| SVM | 0.95 | 20s |
| ANN | 0.96 | 30s |
从表中可以看出,ANN 的准确率最高,但训练时间也最长;逻辑回归的准确率相对较低,但训练时间最短。在实际应用中,需要根据具体需求选择合适的分类器。
分布式计算与 m-brace.NET
在处理大规模数据集时,单机计算可能会遇到性能瓶颈。m-brace.NET 是一个用于在云端集群上进行分布式计算的库,它可以让我们在 F# 脚本环境中轻松地进行分布式计算。
使用 m-brace.NET 进行分布式计算的基本步骤如下:
1.
配置集群
:首先需要配置一个云端集群,如 Azure 或 Amazon EC2。
2.
连接集群
:使用 m-brace.NET 的 API 连接到集群。
3.
定义任务
:将需要并行处理的任务定义为 F# 函数。
4.
分发任务
:将任务分发到集群中的各个节点进行处理。
5.
收集结果
:收集各个节点的处理结果并进行汇总。
以下是一个简单的使用 m-brace.NET 进行分布式计算的示例代码:
#r "Mbrave.Core"
#r "Mbrave.Client"
open Mbrave.Client
open Mbrave.Core
// 连接到集群
let cluster = MbraveFactory.Connect("your-cluster-url")
// 定义任务
let task = cloud {
let result = [1..100] |> List.sum
return result
}
// 分发任务
let job = cluster.CreateProcess(task)
job.Start()
// 收集结果
let result = job.AwaitResult()
printfn "Distributed Result: %d" result
在上述代码中,我们首先连接到集群,然后定义一个计算 1 到 100 之和的任务,将任务分发到集群中进行处理,最后收集处理结果并输出。
总结
本文围绕数字识别器问题,从性能优化和实用工具两个角度,详细探讨了如何优化算法代码。在性能优化方面,我们通过优化距离计算和使用并行计算,显著提高了算法的运行效率;在实用工具方面,我们介绍了 Accord.NET 库,展示了如何使用逻辑回归、SVM 和 ANN 等不同分类器进行数字识别,并比较了它们的性能。此外,我们还介绍了 m-brace.NET 库,用于在云端集群上进行分布式计算,以处理大规模数据集。
在实际应用中,我们应根据具体需求选择合适的优化方法和工具,以达到最佳的性能和效果。同时,不断学习和掌握新的技术和方法,将有助于我们在机器学习领域取得更好的成果。
流程图
graph LR
A[数据准备] --> B[算法选择]
B --> C{是否需要优化?}
C -- 是 --> D[优化距离计算]
C -- 否 --> E[直接训练模型]
D --> F[使用并行计算]
F --> E
E --> G[模型评估]
G --> H{是否满足要求?}
H -- 是 --> I[应用模型]
H -- 否 --> B
列表总结
- 性能优化 :
- 优化距离计算:简化函数、减少数组创建、使用递归和命令式风格等。
-
并行计算:使用
Array.Parallel
模块,注意并行计算的适用场景。 - 实用工具 :
- Accord.NET:包含多种机器学习算法,如逻辑回归、SVM 和 ANN。
- m-brace.NET:用于在云端集群上进行分布式计算。