65.9K
CodeProject 正在变化。 阅读更多。
Home

.NET、TensorFlow 和 Kaggle 的风车

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (15投票s)

2019 年 2 月 23 日

CPOL

7分钟阅读

viewsIcon

20317

.NET 上的 TensorFlow 数据科学实战竞赛

这是关于我作为一名 .NET 开发者,在 Kaggle 竞赛的黑暗森林中持续探索的系列文章。

在本文及后续文章中,我将专注于(几乎)纯神经网络。这意味着,像填充缺失值、特征选择、异常值分析等数据准备中大部分枯燥的部分将被有意忽略。

技术栈将是 C# + TensorFlow tf.keras API。截至今天,还需要 Windows。未来文章中的大型模型可能需要合适的 GPU 来保持其训练时间的可行性。

让我们来预测房地产价格!

房屋价格 是一个非常适合新手入门的竞赛。其数据集很小,没有特殊规则,公共排行榜有很多参与者,而且您每天最多可以提交 4 次。

如果您还没有注册 Kaggle,请立即注册,加入此竞赛,并下载数据。目标是预测 test.csv 中条目的销售价格(SalePrice 列)。存档包含 train.csv,其中有大约 1500 个具有已知销售价格的条目可供训练。在深入神经网络之前,我们将从加载该 数据集 并对其进行一些探索开始。

分析训练数据

我说过要跳过数据准备?我撒谎了!您至少要看一次。

令我惊讶的是,我在 .NET 标准类库中没有找到简单的方法来加载 .csv 文件,所以我安装了一个名为 CsvHelper 的 NuGet 包。为了简化数据操作,我还安装了我新喜欢的 LINQ 扩展包 MoreLinq

static DataTable LoadData(string csvFilePath) {
  var result = new DataTable();
  using (var reader = new CsvDataReader(new CsvReader(new StreamReader(csvFilePath)))) {
    result.Load(reader);
  }
  return result;
}

使用 DataTable 来处理训练数据实际上是一个糟糕的主意。

ML.NET 应该具备 .csv 加载以及许多数据准备和探索操作。然而,当我刚开始参加房屋价格竞赛时,它还没有为此特定目的做好准备。

数据看起来是这样的(只有几行和几列)

ID MSSubClass MSZoning LotFrontage LotArea
1 60 RL 65 8450
2 20 RL 80 9600
3 60 RL 68 11250
4 70 RL 60 9550

加载数据后,我们需要删除 Id 列,因为它与房屋价格实际上无关。

var trainData = LoadData("train.csv");
trainData.Columns.Remove("Id");

分析列数据类型

DataTable 不会自动推断列的数据类型,它假定所有都是 string。因此,下一步是确定我们实际上有什么。对于每一列,我计算了以下统计信息:不同值的数量、其中有多少是整数,以及有多少是浮点数(所有辅助方法的源代码将在文章末尾链接)。

var values = rows.Select(row => (string)row[column]);
double floats = values.Percentage(v => double.TryParse(v, out _));
double ints = values.Percentage(v => int.TryParse(v, out _));
int distincts = values.Distinct().Count();

数值列

事实证明,大多数列实际上是 int,但由于神经网络主要处理浮点数,我们无论如何都会将它们转换为 double

分类列

其他列描述了待售房产所属的类别。它们中没有太多不同的值,这很好。为了将它们用作未来神经网络的输入,它们也必须转换为 double

最初,我简单地将数字从 0 分配给 distinctValueCount - 1,但这没有多大意义,因为实际上从“Facade: Blue”到“Facade: Green”再到“Facade: White”并没有递进关系。因此,我早就将其更改为所谓的 独热编码,其中每个唯一值都有一个单独的输入列。例如,“Facade: Blue”变为 [1,0,0],而“Facade: White”变为 [0,0,1]

将它们全部放在一起

CentralAir: 2 values, ints: 0.00%, floats: 0.00%
Street: 2 values, ints: 0.00%, floats: 0.00%
Utilities: 2 values, ints: 0.00%, floats: 0.00%
....
LotArea: 1073 values, ints: 100.00%, floats: 100.00%

Many value columns:
Exterior1st: AsbShng, AsphShn, BrkComm, BrkFace, CBlock, CemntBd, HdBoard, 
             ImStucc, MetalSd, Plywood, Stone, Stucco, VinylSd, Wd Sdng, WdShing
Exterior2nd: AsbShng, AsphShn, Brk Cmn, BrkFace, CBlock, CmentBd, HdBoard, 
             ImStucc, MetalSd, Other, Plywood, Stone, Stucco, VinylSd, Wd Sdng, Wd Shng
Neighborhood: Blmngtn, Blueste, BrDale, BrkSide, ClearCr, CollgCr, Crawfor, 
              Edwards, Gilbert, IDOTRR, MeadowV, Mitchel, NAmes, NoRidge, NPkVill, 
              NridgHt, NWAmes, OldTown, Sawyer, SawyerW, Somerst, 
              StoneBr, SWISU, Timber, Veenker

non-parsable floats
GarageYrBlt: NA
LotFrontage: NA
MasVnrArea: NA

float ranges:
BsmtHalfBath: 0...2
HalfBath: 0...2
...
GrLivArea: 334...5642
LotArea: 1300...215245

考虑到这一点,我构建了以下 ValueNormalizer,它接受有关列内值的一些信息,并返回一个函数,该函数将一个值(一个 string)转换为神经网络的数值特征向量(double[])。

static Func<string, double[]> ValueNormalizer(double floats, IEnumerable<string> values) {
  if (floats > 0.01) {
    double max = values.AsDouble().Max().Value;
    return s => new[] { double.TryParse(s, out double v) ? v / max : -1 };
  } else {
    string[] domain = values.Distinct().OrderBy(v => v).ToArray();
    return s => new double[domain.Length+1]
                .Set(Array.IndexOf(domain, s)+1, 1);
  }
}

现在,我们已经将数据转换为适合神经网络的格式。是时候构建一个了。

构建神经网络

如果您已经安装了 Python 3.6 和 TensorFlow 1.10.x,您只需要

<PackageReference Include="Gradient" Version="0.1.10-tech-preview4" />

在您的现代 .csproj 文件中。否则,请参考 Gradient 手册 进行初始设置。

一旦包启动并运行,我们就可以创建我们的第一个浅层深度网络。

using tensorflow;
using tensorflow.keras;
using tensorflow.keras.layers;
using tensorflow.train;

...

var model = new Sequential(new Layer[] {
  new Dense(units: 16, activation: tf.nn.relu_fn),
  new Dropout(rate: 0.1),
  new Dense(units: 10, activation: tf.nn.relu_fn),
  new Dense(units: 1, activation: tf.nn.relu_fn),
});

model.compile(optimizer: new AdamOptimizer(), loss: "mean_squared_error");

这将创建一个未训练的神经网络,包含 3 个神经元层和一个 dropout 层,有助于防止过拟合。

tf.nn.relu_fn 是我们神经元的激活函数。 ReLU 以在深度网络中表现良好而闻名,因为它解决了 梯度消失问题:在深度网络中,当误差从输出层反向传播时,原始非线性激活函数的导数往往会变得非常小。这意味着靠近输入层的层只会进行微小的调整,这极大地减缓了深度网络的训练速度。

Dropout 是神经网络中的一种特殊功能层,它实际上本身不包含神经元。相反,它的工作方式是接受每个单独的输入,并随机将其输出替换为 0(否则,它只是传递原始值)。通过这样做,它有助于防止在小型 dataset 中对不太相关的特征 过拟合。例如,如果我们没有删除 Id 列,网络可能已经精确地记住了 <Id>-><SalePrice> 的映射,这将在训练集上给我们带来 100% 的准确率,但在任何其他数据上都会产生完全不相关的数字。我们为什么需要 dropout?我们的训练数据只有大约 1500 个样本,而我们构建的这个微型神经网络有 > 1800 个可调权重。如果它是一个简单的多项式,它可以精确匹配我们试图逼近的价格函数。但是,它会在原始训练集之外的任何输入上产生巨大的值。

馈送数据

TensorFlow 期望其数据是 NumPy 数组或现有张量。我正在将 DataRow 转换为 NumPy 数组。

using numpy;

...

const string predict = "SalePrice";

ndarray GetInputs(IEnumerable<DataRow> rowSeq) {
  return np.array(rowSeq.Select(row => np.array(
      columnTypes
      .Where(c => c.column.ColumnName != predict)
      .SelectMany(column => column.normalizer(
        row.Table.Columns.Contains(column.column.ColumnName)
        ? (string)row[column.column.ColumnName]
        : "-1"))
      .ToArray()))
    .ToArray()
  );
}

var predictColumn = columnTypes.Single(c => c.column.ColumnName == predict);
ndarray trainOutputs = np.array(predictColumn.trainValues
                                             .AsDouble()
                                             .Select(v => v ?? -1)
                                             .ToArray());
ndarray trainInputs = GetInputs(trainRows);

在上面的代码中,我们将每个 DataRow 转换为一个 ndarray,方法是获取其中的每个单元格,并应用与其列相对应的 ValueNormalizer。然后,我们将所有行放入另一个 ndarray 中,得到一个数组的数组。

输出不需要任何此类转换,我们只需将训练值转换为另一个 ndarray

是时候开始梯度下降了

有了这个设置,要训练我们的网络,我们只需要调用模型的 fit 函数。

model.fit(trainInputs, trainOutputs,
          epochs: 2000,
          validation_split: 0.075,
          verbose: 2);

此调用实际上会将最后 7.5% 的训练集用于验证,然后重复以下 2000 次:

  1. 将剩余的 trainInputs 分成批次
  2. 逐个将这些批次馈送到神经网络
  3. 使用我们上面定义的损失函数计算误差
  4. 通过单个神经元连接的梯度反向传播误差,调整权重

在训练过程中,它将输出网络在用于验证的数据上的误差(val_loss)以及网络在训练数据本身上的误差(loss)。通常,如果 val_loss 远大于 loss,则表示网络开始过拟合。我将在后续文章中更详细地讨论这一点。

如果您一切都做得正确,您其中一个损失值的平方根应该在 20000 左右。

提交

我在这里不会详细介绍如何生成要提交的文件。计算输出的代码很简单。

const string SubmissionInputFile = "test.csv";
DataTable submissionData = LoadData(SubmissionInputFile);
var submissionRows = submissionData.Rows.Cast<DataRow>();
ndarray submissionInputs = GetInputs(submissionRows);
ndarray sumissionOutputs = model.predict(submissionInputs);

它主要使用前面定义的函数。

然后您需要将它们写入一个 .csv 文件,该文件只是 Idpredicted_value 对的列表。

当您提交结果时,您应该会获得一个大约 0.17 的分数,这大约在公共排行榜表格的最后四分之一。但嘿,如果像一个拥有 27 个神经元的 3 层网络这么简单,那些讨厌的数据科学家就不会从美国主要公司获得每年 30 万美元以上的总薪酬了。

总结

本文的完整源代码(包含所有辅助函数以及我早期探索和实验的一些注释掉的部分)在 PasteBin 上,大约有 200 行。

在下一篇文章中,您将看到我为了进入公共排行榜前 50% 而进行的折腾。这将是一场业余学徒的冒险,一场与过拟合之风车搏斗的战斗,而游荡者拥有的唯一工具——一个更大的模型(例如,深度神经网络,记住,没有手动特征工程!)。这与其说是一篇编码教程,不如说是一次思维探索,包含非常粗糙的数学和一个奇怪的结论。

敬请期待!

链接

历史

  • 2019 年 2 月 23 日:初始版本
© . All rights reserved.