用于回归的神经网络及其在 C# 中的实现






4.90/5 (3投票s)
如何在 C# 中实现用于回归的神经网络?
引言
近年来,神经网络因其解决复杂问题的能力而备受关注,并已成为深度学习的核心组成部分。虽然它们可以应用于分类和回归等各种任务,但本系列将重点关注它们在回归中的应用。我们将探讨如何训练这些模型,并考察它们的优点和局限性。
本网站之前的文章中已经讨论过神经网络,我们鼓励读者参考这些文章以获得基础知识。
以下书籍对本系列的总结有所助益。
深度学习(Goodfellow, Bengio, Courville)
深度学习:基础与概念(Bishop, Bishop)
机器学习:算法视角(Marsland)
本文最初发布于: 用于回归的神经网络 - 全面概述
什么是神经网络?
引入神经网络是为了有效地捕捉数据中的非线性关系,而传统的算法,如逻辑回归,难以准确地建模模式。虽然神经网络的概念本身并不难理解,但从业者最初在训练它们时面临挑战(特别是,在找到最小化损失函数的最佳参数时)。
我们不会深入探讨神经网络的工作原理,因为我们在之前的文章中已经介绍过。有关更多详细信息,我们建议读者参考以下帖子。
它深入解释了这些结构为何被开发出来,它们解决了什么问题,以及它们如何优于一些知名算法。
什么是回归?
回归是一种统计和机器学习技术,用于建模和预测因变量(也称为目标或输出)与一个或多个自变量(称为特征或输入)之间的关系。回归的主要目标是理解当一个或多个自变量被修改时,因变量如何变化,并利用这种关系进行预测。
重要
回归广泛用于输出是连续变量的预测任务,使其成为统计学和机器学习中的一项重要工具。
我们在学校都接触过回归,通常没有意识到。任何时候我们使用一个公式根据另一个变量预测值(例如,在数学或科学课中绘制穿过数据点的线),我们都在练习基本的回归。
学校中的这些例子相当简单,因为它们通常只涉及一个输入变量,并且我们应用了线性回归,这在图上很容易可视化。

在此上下文中应用线性回归非常简单(这通常是分配给学生的常见作业练习)。
然而,现实世界的情况要复杂得多,通常涉及数百甚至数千个变量。在这种情况下,可视化变得不可能,使得评估我们的近似值是否准确变得更加困难。
此外,决定使用哪种类型的回归成为一个关键问题。我们应该应用线性、二次还是多项式回归?

在这个例子中我们应该使用哪种类型的回归?我们应该应用正弦回归还是多项式回归?即使在这个简单的例子中,只有一个输入,确定选择哪种方法也可能非常具有挑战性。
神经网络可以提供帮助,尝试在无需预定义回归类型的情况下对底层函数进行建模。这种灵活性使它们能够自动捕获数据中的复杂模式,这是它们最大的优点之一。**我们将在本系列中探讨大量的例子。**
整合神经网络与回归
在神经网络进行回归的情况下,输出激活函数通常是恒等函数,并且我们只有一个输出。这将产生以下最终公式,我们将在整个文章中使用它。
公式可能看起来稍微简单一些;然而,如何训练网络的问题仍然存在。具体来说,我们需要确定如何找到最小化损失函数的最佳权重。由于这个过程在数学上非常复杂,我们建议对细节感兴趣的读者参考以下链接(在本网站上呈现数学公式可能非常困难)。
理论够了,上代码!
在介绍了反向传播算法的复杂细节之后,我们现在转向实际应用,在 C# 中实现一个用于回归的神经网络。我们将借鉴上一节的内容,并演示如何编写神经网络代码。我们将尽量保持解释的简洁性。
定义接口
在本节中,我们定义了几个我们将需要实现的接口。为了获得更大的灵活性和可扩展性,这些接口也可以在将来由自定义类进行扩展。
定义激活函数
激活函数是神经网络的关键组成部分,它们将非线性引入模型,使其能够学习和建模数据中的复杂模式。激活函数根据其输入确定神经元的输出。
它们以其自然形式和通过其导数发挥作用,因为导数对于在反向传播过程中计算梯度至关重要。因此,我们将为它们定义以下契约(接口)。
public interface IActivationFunction
{
double Evaluate(double input);
double EvaluateDerivative(double input);
}
激活函数的一个例子是 tanh(双曲正切)函数。
public class TanhActivationFunction : IActivationFunction
{
public double Evaluate(double input)
{
return Math.Tanh(input);
}
public double EvaluateDerivative(double input)
{
return 1 - Math.Pow(Math.Tanh(input), 2);
}
}
由于我们也在处理回归,我们将需要恒等激活函数。
public class IdentityActivationFunction : IActivationFunction
{
public double Evaluate(double input)
{
return input;
}
public double EvaluateDerivative(double input)
{
return 1.0;
}
}
定义训练算法
为了使神经网络有效,我们需要确定最小化成本函数的权重。为了实现这一点,可以使用各种技术,我们可以定义以下契约来指导过程。
public interface IANNTrainer
{
void Train(ANNForRegression ann, DataSet set);
}
我们现在将实现一个梯度下降算法,使用通过反向传播算法计算的导数(如上一篇文章所述)。
public class GradientDescentANNTrainer : IANNTrainer
{
private ANNForRegression _ann;
public void Train(ANNForRegression ann, DataSet set)
{
_ann = ann;
Fit(set);
}
#region Private Methods
private void Fit(DataSet set)
{
var numberOfHiddenUnits = _ann.NumberOfHiddenUnits;
var a = new double[numberOfHiddenUnits];
var z = new double[numberOfHiddenUnits];
var delta = new double[numberOfHiddenUnits];
var nu = 0.1;
// Initialize
var rnd = new Random();
for (var i = 0; i < _ann.NumberOfFeatures; i++)
{
for (var j = 0; j < _ann.NumberOfHiddenUnits; j++)
{
_ann.HiddenWeights[j, i] = rnd.NextDouble();
_ann.HiddenBiasesWeights[j] = rnd.NextDouble();
}
}
for (var j = 0; j < numberOfHiddenUnits; j++)
_ann.OutputWeights[j] = rnd.NextDouble();
_ann.OutputBiasesWeights = rnd.NextDouble();
for (var n = 0; n < 10000; n++)
{
foreach (var record in set.Records)
{
// Forward propagate
z[0] = 1.0;
for (var j = 0; j < _ann.NumberOfHiddenUnits; j++)
{
a[j] = 0.0;
for (var i = 0; i < _ann.NumberOfFeatures; i++)
{
var feature = set.Features[i];
a[j] = a[j] + _ann.HiddenWeights[j, i]*record.Data[feature];
}
// Add biases
a[j] = a[j] + _ann.HiddenBiasesWeights[j];
z[j] = _ann.HiddenActivationFunction.Evaluate(a[j]);
}
var b = 0.0;
for (var j = 0; j < numberOfHiddenUnits; j++)
b = b + _ann.OutputWeights[j] * z[j];
b = b + _ann.OutputBiasesWeights;
var y = b;
// Evaluate the error for the output
var d = y - record.Target;
// Backpropagate this error
for (var j = 0; j < numberOfHiddenUnits; j++)
delta[j] = d * _ann.OutputWeights[j] * _ann.HiddenActivationFunction.EvaluateDerivative(a[j]);
// Evaluate and utilize the required derivatives
for (var j = 0; j < numberOfHiddenUnits; j++)
_ann.OutputWeights[j] = _ann.OutputWeights[j] - nu * d * z[j];
_ann.OutputBiasesWeights = _ann.OutputBiasesWeights - nu * d;
for (var j = 0; j < numberOfHiddenUnits; j++)
{
for (var i = 0; i < _ann.NumberOfFeatures; i++)
{
var feature = set.Features[i];
_ann.HiddenWeights[j, i] = _ann.HiddenWeights[j, i] - nu * delta[j]*record.Data[feature];
}
_ann.HiddenBiasesWeights[j] = _ann.HiddenBiasesWeights[j] - nu * delta[j];
}
}
}
}
#endregion
}
定义神经网络
有了这些接口的定义,实现神经网络就变得相当直接。
public class ANNForRegression
{
public double[,] HiddenWeights { get; set; }
public double[] HiddenBiasesWeights { get; set; }
public double[] OutputWeights { get; set; }
public double OutputBiasesWeights { get; set; }
public int NumberOfFeatures { get; set; }
public int NumberOfHiddenUnits { get; set; }
public IActivationFunction HiddenActivationFunction { get; set; }
public IANNTrainer Trainer { get; set; }
public ANNForRegression(int numberOfFeatures, int numberOfHiddenUnits, IActivationFunction hiddenActivationFunction, IANNTrainer trainer)
{
NumberOfFeatures = numberOfFeatures;
NumberOfHiddenUnits = numberOfHiddenUnits;
HiddenActivationFunction = hiddenActivationFunction;
Trainer = trainer;
HiddenWeights = new double[NumberOfHiddenUnits, NumberOfFeatures];
HiddenBiasesWeights = new double[NumberOfHiddenUnits];
OutputWeights = new double[NumberOfHiddenUnits + 1];
}
public void Train(DataSet set)
{
Trainer.Train(this, set);
}
public double Predict(DataToPredict record)
{
var a = new double[NumberOfHiddenUnits];
var z = new double[NumberOfHiddenUnits];
// Forward propagate
z[0] = 1.0;
for (var j = 0; j < NumberOfHiddenUnits; j++)
{
a[j] = 0.0;
for (var i = 0; i < NumberOfFeatures; i++)
{
var data = record.Data.ElementAt(i);
a[j] = a[j] + HiddenWeights[j, i] * data.Value;
}
// Add biases
a[j] = a[j] + HiddenBiasesWeights[j];
z[j] = HiddenActivationFunction.Evaluate(a[j]);
}
var b = 0.0;
for (var j = 0; j < NumberOfHiddenUnits; j++)
b = b + OutputWeights[j] * z[j];
b = b + OutputBiasesWeights;
return b;
}
}
此代码包含两个值得注意的方法:*Train* 和 *Predict*。*Train* 方法允许我们使用训练算法来训练神经网络,而 *Predict* 方法使我们能够对先前未见过的值进行预测。
关于代码的内容就到这里。现在是时候看看它的实际效果了,我们将探讨神经网络如何逼近我们想要的任何函数。
x↦x²
我们的目标是验证神经网络可以逼近函数 x↦x²。
定义数据集
我们的数据集由一个输入和一个输出组成,具体来说是建模函数 x↦x²。数据点是均匀采样于区间 [−1,1] 上的 xx,并且相应的值已经添加了噪声。

我们的目标是预测未见过输入的对应值。
训练神经网络
我们将首先使用一个具有**十个**隐藏层的神经网络。
internal class Program
{
static void Main(string[] args)
{
var path = AppContext.BaseDirectory + "/dataset01.csv";
var dataset = DataSet.Load(path); var numberOfFeatures = dataset.Features.Count;
var hiddenActivation = new TanhActivationFunction();
var trainer = new GradientDescentANNTrainer();
// Define the neural network
var ann = new ANNForRegression(numberOfFeatures, 10, hiddenActivation, trainer);
// Train the network with the dataset
ann.Train(dataset);
// Predict an unknown value
var p = new DataToPredict()
{
Data = new Dictionary<string, double>
{
{"X", 0.753 }
}
};
var res = ann.Predict(p);
}
}
以下是神经网络为带标签的值确定的结果。

我们可以看到神经网络成功识别了底层函数。现在,让我们预测一个未见过的值,例如 0.753。

神经网络预测 0.5781,而期望值为 0.5670。
x↦cos6x
我们的目标是验证神经网络可以逼近函数 x↦cos6x。
定义数据集
我们的数据集由一个输入和一个输出组成,具体来说是建模函数 x↦cos6x。数据点是均匀采样于区间 [−1,1] 上的 xx,并且相应的值已经添加了噪声。

我们的目标是预测未见过输入的对应值。
训练神经网络
我们将首先使用一个具有**十个**隐藏层的神经网络。
internal class Program
{
static void Main(string[] args)
{
var path = AppContext.BaseDirectory + "/dataset02.csv";
var dataset = DataSet.Load(path); var numberOfFeatures = dataset.Features.Count;
var hiddenActivation = new TanhActivationFunction();
var trainer = new GradientDescentANNTrainer();
// Define the neural network
var ann = new ANNForRegression(numberOfFeatures, 10, hiddenActivation, trainer);
// Train the network with the dataset
ann.Train(dataset);
// Predict an unknown value
var p = new DataToPredict()
{
Data = new Dictionary<string, double>
{
{"X", 0.753 }
}
};
var res = ann.Predict(p);
}
}
以下是神经网络为带标签的值确定的结果。

我们可以看到神经网络成功识别了底层函数。现在,让我们预测一个未见过的值,例如 0.753。

神经网络预测 -0.1512,而期望值为 -0.1932。
x↦H(x)
我们的目标是验证神经网络可以逼近单位阶跃函数。
定义数据集
我们的数据集由一个输入和一个输出组成,具体来说是建模单位阶跃函数。数据点是均匀采样于区间 [−1,1] 上的 xx,并且相应的值已经添加了噪声。

我们的目标是预测未见过输入的对应值。
训练神经网络
我们将首先使用一个具有**十个**隐藏层的神经网络。
internal class Program
{
static void Main(string[] args)
{
var path = AppContext.BaseDirectory + "/dataset04.csv";
var dataset = DataSet.Load(path); var numberOfFeatures = dataset.Features.Count;
var hiddenActivation = new TanhActivationFunction();
var trainer = new GradientDescentANNTrainer();
// Define the neural network
var ann = new ANNForRegression(numberOfFeatures, 10, hiddenActivation, trainer);
// Train the network with the dataset
ann.Train(dataset);
// Predict an unknown value
var p = new DataToPredict()
{
Data = new Dictionary<string, double>
{
{"X", 0.753 }
}
};
var res = ann.Predict(p);
}
}
以下是神经网络为带标签的值确定的结果。

我们可以看到神经网络成功识别了底层函数。现在,让我们预测一个未见过的值,例如 0.753。

神经网络预测 0.9934,而期望值为 1。
从这些各种例子中,我们可以看到神经网络非常适合逼近一系列函数,包括一些甚至不连续的函数。然而,现在我们将探讨这些数据结构的弱点。
我们应该使用多少个隐藏单元?
在之前的例子中,我们任意使用了十个隐藏单元作为我们的神经网络。但这是否是一个合适的值?为了确定这一点,我们将尝试不同的值,看看函数是否仍然被准确逼近。
信息
我们将使用函数 x↦cos6x 进行实验。
具有 2 个隐藏单元
我们将开始实验,只使用两个隐藏单元来观察神经网络的行为。

很明显,逼近效果相当差,这表明网络缺乏足够的灵活性来准确建模函数。**这种现象被称为欠拟合。**
具有 4 个隐藏单元
我们将进行相同的实验,但这次使用四个隐藏单元。

很明显,逼近效果仍然相当差。
具有 6 个隐藏单元
我们将进行相同的实验,但这次使用六个隐藏单元。

逼近效果现在非常准确;然而,这个例子说明确定隐藏单元的最佳数量可能相当具有挑战性。在我们的案例中,相对容易确定逼近效果是否良好;然而,在现实世界的场景中,拥有数百甚至数千个维度,这变得更加复杂。**确定最佳隐藏单元数量更像是一种艺术,而不是科学。**
结论
如果隐藏单元不够多,存在遇到欠拟合现象的风险。相反,如果隐藏单元太多,我们可能会遇到过拟合或消耗过多的资源。
神经网络可以外插值吗?
在上一篇文章中,我们研究了神经网络如何内插未见过的值。我们特别关注了 0.753 的值,发现它产生了相对准确的预测。然而,我们可以质疑神经网络是否能够确定外插值(特别是,初始区间之外的值)。在我们的案例中,例如 2 的预测值会是多少?
信息
再次,我们将使用函数 x↦cos6x 进行实验,并将隐藏单元的数量设置为 6。

神经网络预测 1.2397,而期望值为 0.8438。
**因此,神经网络无法准确预测外插值。这种现象具有重大意义:为了确保对所有输入进行准确预测,我们必须确保数据集的完整性。特别是,为所有可能的特征提供代表性值至关重要。**否则,网络可能会产生不可靠的预测。
既然我们已经通过示例深入探讨了用于回归的神经网络,我们将研究它们如何在实际场景中应用。为避免使本文过于冗长,有兴趣了解此实现的读者可以在此处找到后续内容。