从 C# 桌面应用程序调用 TensorFlow AI (Python)





5.00/5 (12投票s)
演示了如何从 C# 应用程序调用 TensorFlow 神经网络,以及如何使用 Python 生成的图表来显示结果。
引言
本文展示了一个 C# 桌面应用程序,该应用程序调用了两个最初用 Python 编写的 TensorFlow AI 模型。为此,它使用了 PythonRunner
类,我在 之前的文章中对此进行了更详细的介绍。它本质上是一个组件,允许您从 C# 代码(使用可变参数列表)同步和异步调用 Python 脚本,并返回其输出(也可以是图表或图形 - 在这种情况下,它将作为 Bitmap 返回到 C# 客户端应用程序)。通过使 Python 脚本易于从 C# 客户端代码调用,这些客户端可以完全访问(数据)科学、机器学习和人工智能的世界,并且,此外,还可以访问 Python 丰富的功能齐全的图表、绘图和数据可视化库(例如 matplotlib 和 seaborn)。
虽然该应用程序使用了 Python/TensorFlow AI 堆栈,但本文无意成为这些问题的介绍。有无数其他来源可供参考,其中大多数比我能写的要好得多、更有指导意义。(您只需启动您选择的搜索引擎……)。相反,本文重点关注 Python 和 C# 之间的桥梁,展示了如何以通用方式调用 Python 脚本,被调用的脚本必须如何构建,以及脚本的输出如何在 C# 端被处理。简而言之:一切都是关于 *集成* Python 和 C#。
现在来看示例应用程序:它旨在对潦草数字(0-9)进行分类。为此,它调用了两个使用 Keras 创建和训练的神经网络。Keras 是一个 Python 包,本质上是一个高级神经网络 API,用 Python 编写,可以在 Google 的 TensorFlow 平台之上运行。Keras 可以非常轻松简单地创建强大的神经网络(创建、训练和评估两个模型中最简单的那个模型的脚本只有 15 行代码!),并且是 Python 世界中 AI 的准标准。
这些模型使用 MNIST 数据集进行训练和验证。该数据集由 60,000/10,000(训练/测试)标准化数字 0-9 的图像组成,每张图像均为 8bpp 反转灰度格式,尺寸为 28x28 像素。MNIST 数据集在 AI(尤其是在示例、教程等方面)中非常常见,以至于有时被称为 *AI 编程的“Hello World”*(并且也原生包含在 Keras 包中)。下面是一些 MNIST 图像示例
动机和背景
作为一名拥有近 15 年经验的自由 C# 开发人员,我几个月前开始了我进入 ML 和 AI 的旅程。我这样做部分是因为我想拓宽我作为开发人员的技能,部分是因为我对 ML 和 AI 的炒作感到好奇。特别是,我被许多标题为“Python vs. C#”或类似的 YouTube 视频所困扰。当我获得一些个人见解后,我发现这个问题在很大程度上不是问题——仅仅因为这两种编程语言代表了两种完全不同的工作。因此,问题应该更像是“数据科学家/业务分析师/AI 专家 vs. 软件开发人员”。这两种都是完全不同的工作,具有不同的理论背景和不同的业务领域。一个普通人(或略高于平均水平的人)不可能在这两个领域都表现出色——此外,我认为从业务/组织的角度来看,这也不是理想的。这就像同时尝试成为一名水管工和一名机械师——我怀疑这样一个人能在任何一个职业中名列前茅。
这就是为什么我认为那些试图将 ML 和 AI 带入 .NET/C# 世界的组件(例如 Microsoft 的 ML.NET 或 SciSharp STACK)本身可能很棒,但它们稍微偏离了重点。 IMHO,更有意义的是 **连接** 这两个领域,从而促进公司的数据科学家/业务分析师/AI 专家与应用程序开发人员之间的协作。我将在这里介绍的示例应用程序演示了如何做到这一点。
必备组件
在我更详细地描述应用程序并重点介绍一些值得注意的代码行之前,我应该澄清一些准备工作。
Python 环境
首先,我们需要一个正在运行的 Python 环境。(注意:Python 版本必须是 3.6,64 位!TensorFlow 需要此版本。)此外,应安装以下软件包
不用担心:对于新手来说,这看起来很长,但对于经验丰富的科学/AI/ML 程序员来说,这相当标准。如果您不确定如何安装环境或软件包:您可以在网上找到大量关于这些问题的教程和操作指南(文字和视频都有)。
现在,我们需要最后一步,告诉示例应用程序在哪里可以找到 Python 环境。这在应用程序的App.config文件中完成(其中还包含脚本子文件夹的名称和超时值)。
...
<appSettings>
<add key="pythonPath"
value="C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\Python36_64\\python.exe" />
<add key="scripts" value="scripts" />
<add key="scriptTimeout" value="30000" />
</appSettings>
...
构建和训练 AI 模型
如上所述,示例应用程序调用两个不同的神经网络并组合它们的结果。第一个是一个非常简单的 神经网络,带有两个隐藏层(每个 128 个节点),第二个是稍微复杂一点的 卷积神经网络(简称 CNN)。这两个网络都使用已描述的 MNIST 数据集进行训练和评估,准确率分别为 94.97%(简单)和 98.05%(CNN)。训练好的模型被保存,然后将文件/文件夹复制到示例应用程序期望的位置。
对于那些对创建和训练两个模型的详细信息感兴趣的人(这非常简单):相应的脚本可以在解决方案的 scripts/create_train_{simple|cnn}.py 下找到。
示例应用程序
在我们最终深入研究源代码之前,让我简要解释一下示例应用程序的各个区域和功能。
绘制数字
应用程序窗口的左上角区域包含一个画布,您可以在其中用鼠标绘制一个数字。
绘图画布下方的左侧按钮(带星号的那个)清除当前图形,右侧按钮则通过调用 predict.py Python 脚本来进行实际预测。小预览图像显示了用于预测的像素(即,它们作为参数提供给 predict.py)。这是当前绘图的调整大小和灰度化的 8bpp 版本。—— 更复杂和细致的图像预处理步骤在调用的 Python 脚本中完成。虽然这不完全是本文的范围,但请注意,您可以通过检查 predict.py 脚本来深入了解详细信息(图像优化部分的代码很大程度上受到 这篇博文的启发)。
原始模型输出
右上角的组框(“详细预测结果(百分比,未加权)”)包含来自两个 AI 模型的原始输出,作为一系列百分比——每个数字一个值。这些值实际上是网络输出层的激活权重,但在数字分类的上下文中可以解释为概率。浅蓝色框包含来自简单网络的输出,较深蓝色的框包含来自 CNN 的输出。
加权两个模型
窗口右下方的滑块允许您更改两个模型在计算最终预测结果时的相对权重。此最终结果是根据两个模型的原始输出来加权平均计算的。
然后,该最终结果会显示在窗口中间的大框中,并附有其正确率的计算概率。
显示计算结果为堆叠条形图
最后,应用程序窗口底部的大部分区域用于显示一个堆叠条形图,其中包含预测的加权结果(图表由另一个 Python 脚本 chart.py 生成)。同样,采用较浅/较深的蓝色编码。
关注点
现在我们从用户的角度更全面地了解了应用程序的各个方面,让我们来看看一些有趣的代码片段。
代码的主要结构
通常,项目包括
- C# 文件,
scripts
子文件夹中的 Python 脚本(predict.py
、chart.py
),scripts/model
子文件夹中的两个 TensorFlow AI 模型(简单模型存储为文件夹结构,CNN 存储为单个文件)。
应用程序的 C# 部分使用 WPF 并遵循 MVVM 架构模式。换句话说:视图代码(仅包含 XAML)位于 MainWindow
类中,而 MainViewModel
包含应用程序数据并执行(不太复杂的)计算。
SnapshotCanvas
首先要注意的是绘图控件。我为此使用了常规 InkCanvas
控件的派生类。它通过 Snapshot
依赖项属性进行了增强,该属性在用户向画布添加新笔画时会连续将其呈现为位图。
/// <summary>
/// Specialized derivation from the <see cref="System.Windows.Controls.InkCanvas" />
/// class that continuously makes a bitmap <see cref="Snapshot" /> of itself.
/// </summary>
/// <seealso cref="Snapshot" />
public class SnapshotInkCanvas : System.Windows.Controls.InkCanvas
{
#region Properties
/// <summary>
/// Gets the current controls drawing as bitmap snapshot.
/// </summary>
/// <remarks>
/// Backed by the <see cref="SnapshotProperty" /> dependency property.
/// </remarks>
public Bitmap Snapshot
{
get => (Bitmap)GetValue(SnapshotProperty);
set => SetValue(SnapshotProperty, value);
}
#endregion // Properties
#region Dependency Properties
/// <summary>
/// Dependency property for the <see cref="Snapshot" /> CLR property.
/// </summary>
public static readonly DependencyProperty SnapshotProperty = DependencyProperty.Register(
"Snapshot",
typeof(Bitmap),
typeof(SnapshotInkCanvas));
#endregion // Dependency Properties
#region Construction
public SnapshotInkCanvas()
{
StrokeCollected += (sender, args) => UpdateSnapshot();
StrokeErased += (sender, args) => Snapshot = null;
}
#endregion // Construction
#region Implementation
/// <summary>
/// Makes a new <see cref="Snapshot" /> of itself.
/// </summary>
/// <remarks>
/// This happens in reaction to the user adding a new stroke to the canvas.
/// </remarks>
private void UpdateSnapshot()
{
var renderTargetBitmap = new RenderTargetBitmap(
(int)Width,
(int)Height,
StandardBitmap.HorizontalResolution,
StandardBitmap.VerticalResolution,
PixelFormats.Default);
var drawingVisual = new DrawingVisual();
using (DrawingContext ctx = drawingVisual.RenderOpen())
{
ctx.DrawRectangle(
new VisualBrush(this),
null,
new Rect(0, 0, Width, Height));
}
renderTargetBitmap.Render(drawingVisual);
var encoder = new BmpBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(renderTargetBitmap));
using (var stream = new MemoryStream())
{
encoder.Save(stream);
Snapshot = new Bitmap(stream);
}
}
#endregion // Implementation
}
控件的 Snapshot
然后通过 XAML 绑定……
<local:SnapshotInkCanvas Snapshot="{Binding CanvasAsBitmap, Mode=OneWayToSource}" ... />
……到 MainViewModel
的 CanvasAsBitmap
属性
/// <summary>
/// Continuously receives a snapshot of the drawing canvas.
/// </summary>
/// <seealso cref="SnapshotInkCanvas.Snapshot" />
public Bitmap CanvasAsBitmap
{
set
{
_canvasAsBitmap = value;
UpdatePreview();
}
}
(请注意,这是数据流仅为外部到内部的少数情况之一(绑定为 OneWayToSource
)。)
PythonRunner
PythonRunner
类是应用程序的核心。它是一个组件,允许您从 C# 客户端运行 Python 脚本,使用可变参数列表,同步和异步运行。脚本可以生成文本输出以及图像,这些图像将被转换为 C# 图像。我在上一篇文章(“使用 C# 客户端调用 Python 脚本(包括图表和图像)”)中更详细地描述了该类(以及被调用 Python 脚本必须满足的条件)。
启动预测过程
还记得上面截图中的这个小“执行”按钮吗?这就是触发操作和所有 AI 魔术开始的地方。以下命令方法通过 WPF 的命令绑定绑定到该按钮。
/// <summary>
/// Asynchronously runs the scripts and updates the properties
/// according to the returned results.
/// </summary>
private async void ExecEvaluateAsync(/*unused*/ object o = null)
{
try
{
if (IsProcessing) // This should never happen,
// but in programming it's better to be paranoid ;-) ...
{
throw new InvalidOperationException("Calculation is already running.");
}
IsProcessing = true; // Avoid overlapping calls.
if (_previewBitmap == null) // Again, this never should happen, but you know ;-) ...
{
throw new InvalidOperationException("No drawing data available.");
}
byte[] mnistBytes = GetMnistBytes(); // Gets the bytes of the preview bitmap
// (length is 28 x 28 = 784)
SetPredictionResults(await RunPrediction(mnistBytes)); // Get (and process)
// raw prediction values.
await DrawChart(); // Now, that the results are available, draw the stacked,
// horizontal barchart.
}
catch (PythonRunnerException runnerException)
{
_messageService.Exception(runnerException.InnerException); // Notify user.
}
catch (Exception exception)
{
_messageService.Exception(exception); // Notify user.
}
finally
{
IsProcessing = false;
CommandManager.InvalidateRequerySuggested(); // Causes an update for the buttons'
// enabled states.
}
}
如我们所见,命令方法——除了进行一些样板代码和错误检查外——异步调用执行数字分类(RunPrediction(...)
)的方法,然后调用绘制条形图(DrawChart()
)的方法。接下来,我们将逐步介绍这两个过程。
两个模型的原始结果
从两个神经网络获取(并解析)原始结果
为了使用两个 TensorFlow AI 模型处理用户输入,C# 客户端应用程序首先调用 Python 脚本(predict.py,路径存储在 viewmodel 的 _predictScript
字段中)。该脚本期望一个逗号分隔的列表,表示数字的灰度字节。
/// <summary>
/// Calls the python script to retrieve a textual list of
/// the output of the two neural networks.
/// </summary>
/// <returns>
/// An awaitable task, with the text output of the script as
/// <see cref="Task{TResult}.Result" />.
/// </returns>
/// <remarks>
/// The output from the script should be two lines, each consisting of a series
/// of ten floating point numbers - one for each AI model (simple model first),
/// representing the probabilities for a digit from 0-9.
/// </remarks>
private async Task<string> RunPrediction(byte[] mnistBytes)
{
return await _pythonRunner.ExecuteAsync(
_predictScript,
mnistBytes.Select(b => b.ToString()).Aggregate((b1, b2) => b1 + "," + b2));
}
脚本的输出(即返回到 viewmodel 的字符串)如下所示:
00.18 02.66 16.88 04.23 22.22 07.27 05.78 01.40 29.66 09.74
00.00 00.11 52.72 00.09 00.30 00.04 00.02 00.77 45.94 00.00
每一行代表一个数字 0-9 的概率序列(以百分比显示),第一行代表简单神经网络的输出,第二行是 CNN 的结果。
然后,此输出字符串被解析为两个字符串数组,它们存储在 viewmodel 的 PredictionResultSimple
和 PredictionResultCnn
属性中。此外,SetPredictionText()
方法将两个结果格式化为人类可读的字符串,以便呈现给用户。
/// <summary>
/// Parses the script output as retrieved by <see cref="RunPrediction" />.
/// </summary>
/// <param name="prediction">
/// The output from <c>predict.py</c>.
/// </param>
private void SetPredictionResults(string prediction)
{
_predictionResult = prediction;
if (string.IsNullOrEmpty(_predictionResult))
{
PredictionResultSimple = null;
PredictionResultCnn = null;
}
else
{
string[] lines = _predictionResult.Split('\n');
PredictionResultSimple = lines[0].Split(new[] { ' ' },
StringSplitOptions.RemoveEmptyEntries);
PredictionResultCnn = lines[1].Split(new[] { ' ' },
StringSplitOptions.RemoveEmptyEntries);
}
SetPredictionText();
OnPropertyChanged(nameof(PredictionResultSimple));
OnPropertyChanged(nameof(PredictionResultCnn));
}
...
/// <summary>
/// Gets the prediction result of the simple neural network model.
/// </summary>
/// <value>
/// The prediction result of the simple model as an array of ten
/// numbers which represent percentages for digits 0-9.
/// Example: <c>"00.00 11.11 22.22 ..."</c>
/// </value>
public string[] PredictionResultSimple { get; private set; }
/// <summary>
/// Gets the prediction result of the convolutional neural network model.
/// </summary>
/// <value>
/// The prediction result of the convolutional model as an array of ten
/// numbers which represent percentages for digits 0-9.
/// Example: <c>"00.00 11.11 22.22 ..."</c>
/// </value>
public string[] PredictionResultCnn { get; private set; }
...
/// <summary>
/// Helper: Formats some prediction text.
/// </summary>
public void SetPredictionText()
{
if (PredictionResultSimple == null || PredictionResultCnn == null)
{
PredictionTextSimple = null;
PredictionTextCnn = null;
}
else
{
string fmt = "{0}: '{1}'\n(Certainty: {2}%)";
string percentageSimple = PredictionResultSimple.Max();
int digitSimple = Array.IndexOf(PredictionResultSimple, percentageSimple);
PredictionTextSimple = string.Format(fmt, "Simple Neural Network",
digitSimple, percentageSimple);
string percentageCnn = PredictionResultCnn.Max();
int digitCnn = Array.IndexOf(PredictionResultCnn, percentageCnn);
PredictionTextCnn = string.Format(fmt, "Convolutional Neural Network",
digitCnn, percentageCnn);
}
OnPropertyChanged(nameof(PredictionTextSimple));
OnPropertyChanged(nameof(PredictionTextCnn));
SetWeightedPrediction();
}
(在这种情况下,要找到具体的预测数字非常简单:由于数字代表 0-9 的概率,因此所讨论的数字等于数组中最高值的索引。)
显示原始结果
现在我们有了两个神经网络的预测结果,剩下要做的就是将我们的 viewmodel 的 PredictionResultSimple
和 PredictionResultCnn
属性绑定到 UI 中相应的控件,即上面显示的只读文本框系列。使用数组语法可以轻松完成此操作。
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding PredictionResultSimple[0]}" />
<TextBox Grid.Row="1" Grid.Column="1"
Text="{Binding PredictionResultSimple[1]}" />
...
<TextBox Grid.Row="0" Grid.Column="3"
Text="{Binding PredictionResultCnn[0]}" />
<TextBox Grid.Row="1" Grid.Column="3"
Text="{Binding PredictionResultCnn[1]}" />
加权结果
计算和显示加权结果
现在已经有了两个神经网络的未加权输出,我们可以继续计算加权的总体结果。
请记住,用户可以通过上面显示的滑块控件来改变两个网络的相对权重。因此,在 MainWindow.xaml 中,我们将 Slider 控件的 Value
属性绑定到 viewmodel 的 Weight
属性。
<Slider Grid.Row="1"
Orientation="Vertical"
HorizontalAlignment="Center"
Minimum="0.1" Maximum="0.9"
TickFrequency="0.1"
SmallChange="0.1"
LargeChange="0.3"
TickPlacement="Both"
Value="{Binding Weight}" />
然后,在 viewmodel 中,Weight
属性用于计算两个 AI 模型输出的加权平均值。
/// <summary>
/// Gets or sets the relative weight that is used to calculate the
/// weighted average from the two neural network models.
/// </summary>
/// <value>
/// The relative weight of the two models, a value between 0 and 1.
/// </value>
/// <remarks>
/// Calculation is as follows:
/// <list type="bullet">
/// <item><description>
/// The value itself specifies the relative weight for the simple
/// neural network model (see <see cref="WeightSimple" />).
/// </description></item><item><description>
/// The value of <c>(1 - value)</c> specifies the relative weight for the
/// convolutional neural network model (see <see cref="WeightCnn" />).
/// </description></item><item><description>
/// The final prediction then is calculated as <c>[result from simple model] * value -
/// [result from convolutional model] * (1 - value)</c>.
/// </description></item>
/// </list>
/// </remarks>
/// <seealso cref="WeightSimple" />
/// <seealso cref="WeightCnn" />
public double Weight
{
get => _weight;
set
{
if (Math.Abs(_weight - value) > 0.09999999999)
{
_weight = Math.Round(value, 2);
_simpleFactor = _weight;
_cnnFactor = 1 - _weight;
OnPropertyChanged();
OnPropertyChanged(nameof(WeightSimple));
OnPropertyChanged(nameof(WeightCnn));
ClearResults();
}
}
}
...
/// <summary>
/// Helper: Calculates the weighted prediction and sets some related properties.
/// </summary>
/// <remarks>
/// For a more detailed explanation of the weighting calculation
/// see the <see cref="Weight" /> property.
/// </remarks>
/// <seealso cref="Weight" />
/// <seealso cref="WeightSimple" />
/// <seealso cref="WeightCnn" />
private void SetWeightedPrediction()
{
if (PredictionResultSimple == null || PredictionResultCnn == null)
{
WeightedPrediction = '\0';
WeightedPredictionCertainty = null;
}
else
{
double[] combinedPercentages = new double[10];
for (int i = 0; i < 10; i++)
{
combinedPercentages[i] =
(Convert.ToDouble(PredictionResultSimple[i]) * _simpleFactor * 2 +
Convert.ToDouble(PredictionResultCnn[i]) * _cnnFactor * 2) / 2;
}
double max = combinedPercentages.Max();
WeightedPrediction = Array.IndexOf(combinedPercentages, max).ToString()[0];
WeightedPredictionCertainty = $"({max:00.00}%)";
}
OnPropertyChanged(nameof(WeightedPredictionCertainty));
OnPropertyChanged(nameof(WeightedPrediction));
}
调用脚本获取堆叠条形图
用于可视化显示加权结果的条形图由另一个 Python 脚本 chart.py 生成。它接收来自另一个脚本的输出(即,包含百分比值的两行),以及简单神经网络和卷积神经网络的权重。这是 MainViewModel
类中的相应方法调用,它设置了 BarChart
和 ChartTitle
属性。
/// <summary>
/// Calls the python script to draw the bar chart of the probabilities.
/// </summary>
/// <returns>An awaitable <see cref="Task" />.</returns>
internal async Task DrawChart()
{
BarChart = null;
ChartTitle = null;
var bitmap = await _pythonRunner.GetImageAsync(
_chartScript,
_predictionResult,
_simpleFactor,
_cnnFactor);
if (bitmap != null)
{
BarChart = Imaging.CreateBitmapSourceFromHBitmap(
bitmap.GetHbitmap(),
IntPtr.Zero,
Int32Rect.Empty,
BitmapSizeOptions.FromEmptyOptions());
ChartTitle = $"Weighted Prediction Result (Conv.: {_cnnFactor * 100}%, " +
$"Simple: {_simpleFactor * 100}%)";
}
OnPropertyChanged(nameof(ChartTitle));
}
...
/// <summary>
/// The bar chart object. Displays the numbers of <see cref="PredictionResultSimple" />
/// and <see cref="PredictionResultCnn" /> graphically (after applying <see cref="Weight" />).
/// </summary>
/// <seealso cref="PredictionResultSimple" />
/// <seealso cref="PredictionResultCnn" />
/// <seealso cref="Weight" />
public BitmapSource BarChart
{
get => _barChart;
set
{
if (!Equals(_barChart, value))
{
_barChart = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// Gets the title for the <see cref="BarChart" />.
/// </summary>
/// <value>The chart title.</value>
public string ChartTitle { get; private set; }
(请注意,原始 Bitmap 已转换为 BitmapSource 类型对象,这是一个 WPF 中用于图像处理的专用类。)
最后一步是将 BarChart
属性绑定到 WPF 的 Image 控件。
<Image Source="{Binding BarChart}" />
历史
- 2019 年 10 月 10 日:初始版本