从 C# 客户端使用 Python 脚本(包括图表和图像)





5.00/5 (37投票s)
演示如何从 C# 运行 Python 脚本
引言
本文介绍了一个允许您从 C# 客户端运行 Python 脚本的类 (PythonRunner
)。这些脚本可以生成文本输出以及图像,这些图像将被转换为 C# 的 Image 对象。通过这种方式,PythonRunner
类不仅为 C# 应用程序提供了访问数据科学、机器学习和人工智能世界的途径,还将 Python 在图表、绘图和数据可视化方面的丰富库(例如 matplotlib 和 seaborn)提供给了 C#。
背景
一般性考虑
我是一名 C# 开发者,已经超过十年了——我不得不说:这么多年来,我深深地爱上了这门语言。它给了我很大的架构灵活性,拥有庞大的社区支持,以及大量的第三方工具(免费和商业的都有,很多都是开源的),能够支持几乎所有可以想象到的用例。C# 是一种多用途的语言,是商业应用程序开发的头号选择。
在过去的几个月里,我开始学习用于数据科学、机器学习、人工智能和数据可视化的 Python——主要是因为我认为这项技能将推动我作为一名自由软件开发者的职业生涯。我很快意识到 Python 在上述任务方面非常出色(远比 C# 更好),但它完全不适合开发和维护大型商业应用程序。因此,关于“C# 与 Python”(互联网上讨论广泛的话题)的问题完全抓错了重点。C# 适合那些从事业务规模应用程序开发的开发者——Python 适合那些从事数据科学、机器学习和数据可视化的数据科学家。这两个职位之间几乎没有共同之处。
任务不是让 C# 开发者也成为数据科学家或 ML 专家(反之亦然)。同时成为两个领域的专家实在太难了。在我看来,这是诸如 Microsoft 的 ML.NET 或 SciSharp STACK 等组件无法广泛使用的主要原因。普通的 C# 开发者不会成为数据科学家,而数据科学家也不会学习 C#。为什么要学呢?他们已经拥有了非常适合他们需求的优秀编程语言,并且拥有庞大的科学第三方库生态系统。
考虑到这些因素,我开始寻找一种更简单、更“自然”的方式来连接 Python 世界和 C# 世界。以下是一种可能的解决方案……
示例应用程序
在深入细节之前,先做一个简短的说明:我编写此示例应用程序的唯一目的是演示 C#/Python 集成,仅使用了少量稍复杂的 Python 代码。我并没有过多关注 ML 代码本身是否有用,所以在这方面请您多多包涵。
话虽如此,让我简要描述一下示例应用程序。基本上,它
- 提供一个可供选择的股票列表(6-30 只),
- 绘制(标准化后的)月度股票价格的汇总折线图,
- 根据股票价格变动执行所谓的“k 均值聚类分析”,并在
treeview
中显示结果。
让我们逐一快速浏览应用程序的三个部分……
股票选择
应用程序窗口左侧的 DataGrid 显示了可供选择的可用股票列表。在进行进一步操作之前,您需要至少选择六项(最多可选择 30 只股票)。您可以使用顶部的控件过滤列表。此外,通过单击列标题可以对列表进行排序。随机选择按钮可以从列表中随机选择 18 只股票。
调整其他参数
除了股票选择之外,您还可以调整分析的其他参数:分析的日期范围以及 k 均值分析的聚类元参数数量。此数量不能大于所选股票的数量。
分析结果
完成股票选择和参数调整后,您可以按窗口右下角的分析按钮。这将(异步)调用执行上述步骤(绘制图表和执行 k 均值聚类分析)的 Python 脚本。返回后,它将处理并显示脚本的输出。
窗口的中间部分是一个图表,显示了所选股票的价格。这些价格经过标准化处理,使得起始日期的价格设置为零,股票价格按与该起点的百分比变化进行缩放。运行脚本生成的 Image 被包装在一个 ZoomBox 控件中,以增强可访问性和用户体验。
在窗口的最右侧,显示了一个树形结构,其中包含聚类分析的处理结果。它根据股票的相对价格变动对股票进行分组(聚类)(换句话说:两只股票的变动越接近,它们越有可能在同一个聚类中)。此树形结构也用作图表的颜色图例。
关注点
代码的主要结构
总的来说,该项目由以下部分组成:
- C# 文件
- 位于 scripts 子文件夹中的 Python 脚本(chart.py、kmeans.py 和 common.py)
- C# 代码和 Python 脚本都能访问的 SQLite 数据库(
stockdata.sqlite
)
其他注意事项
- 在 C# 端,数据库是使用 EF6 和 此 CodeProject 文章中的方法访问的。
- 一些 WPF UI 控件来自 Extended WPF Toolkit™。
- 当然,目标系统上必须安装包含所有必需包的 Python 环境。相应的路径通过 app.config 文件进行配置。
- 应用程序的 C# 部分使用 WPF 并遵循 MVVM 模式。根据应用程序主窗口的三重整体结构,有三个 ViewModel(
StockListViewModel
、ChartViewModel
和TreeViewViewModel
),由第四个 ViewModel(MainViewModel
)进行协调。
C# 端
PythonRunner 类
运行 Python 脚本的核心组件是 PythonRunner
类。它基本上是对 Process 类的封装,专门用于 Python。它支持文本输出和图像输出,同步和异步均可。以下是该类的 public
接口,以及解释细节的代码注释。
/// <summary>
/// A specialized runner for python scripts. Supports textual output
/// as well as image output, both synchronously and asynchronously.
/// </summary>
/// <remarks>
/// You can think of <see cref="PythonRunner" /> instances <see cref="Process" />
/// instances that were specialized for Python scripts.
/// </remarks>
/// <seealso cref="Process" />
public class PythonRunner
{
/// <summary>
/// Instantiates a new <see cref="PythonRunner" /> instance.
/// </summary>
/// <param name="interpreter">
/// Full path to the Python interpreter ('python.exe').
/// </param>
/// <param name="timeout">
/// The script timeout in msec. Defaults to 10000 (10 sec).
/// </param>
/// <exception cref="ArgumentNullException">
/// Argument <paramref name="interpreter" /> is null.
/// </exception>
/// <exception cref="FileNotFoundException">
/// Argument <paramref name="interpreter" /> is an invalid path.
/// </exception>
/// <seealso cref="Interpreter" />
/// <seealso cref="Timeout" />
public PythonRunner(string interpreter, int timeout = 10000) { ... }
/// <summary>
/// Occurs when a python process is started.
/// </summary>
/// <seealso cref="PyRunnerStartedEventArgs" />
public event EventHandler<PyRunnerStartedEventArgs> Started;
/// <summary>
/// Occurs when a python process has exited.
/// </summary>
/// <seealso cref="PyRunnerExitedEventArgs" />
public event EventHandler<PyRunnerExitedEventArgs> Exited;
/// <summary>
/// The Python interpreter ('python.exe') that is used by this instance.
/// </summary>
public string Interpreter { get; }
/// <summary>
/// The timeout for the underlying <see cref="Process" /> component in msec.
/// </summary>
/// <remarks>
/// See <see cref="Process.WaitForExit(int)" /> for details about this value.
/// </remarks>
public int Timeout { get; set; }
/// <summary>
/// Executes a Python script and returns the text that it prints to the console.
/// </summary>
/// <param name="script">Full path to the script to execute.</param>
/// <param name="arguments">Arguments that were passed to the script.</param>
/// <returns>The text output of the script.</returns>
/// <exception cref="PythonRunnerException">
/// Thrown if error text was outputted by the script (this normally happens
/// if an exception was raised by the script). <br />
/// -- or -- <br />
/// An unexpected error happened during script execution. In this case, the
/// <see cref="Exception.InnerException" /> property contains the original
/// <see cref="Exception" />.
/// </exception>
/// <exception cref="ArgumentNullException">
/// Argument <paramref name="script" /> is null.
/// </exception>
/// <exception cref="FileNotFoundException">
/// Argument <paramref name="script" /> is an invalid path.
/// </exception>
/// <remarks>
/// Output to the error stream can also come from warnings, that are frequently
/// outputted by various python package components. These warnings would result
/// in an exception, therefore they must be switched off within the script by
/// including the following statement: <c>warnings.simplefilter("ignore")</c>.
/// </remarks>
public string Execute(string script, params object[] arguments) { ... }
/// <summary>
/// Runs the <see cref="Execute"/> method asynchronously.
/// </summary>
/// <returns>
/// An awaitable task, with the text output of the script as
/// <see cref="Task{TResult}.Result"/>.
/// </returns>
/// <seealso cref="Execute"/>
public Task<string> ExecuteAsync(string script, params object[] arguments) { ... }
/// <summary>
/// Executes a Python script and returns the resulting image
/// (mostly a chart that was produced
/// by a Python package like e.g. <see href="https://matplotlib.net.cn/">matplotlib</see> or
/// <see href="https://seaborn.org.cn/">seaborn</see>).
/// </summary>
/// <param name="script">Full path to the script to execute.</param>
/// <param name="arguments">Arguments that were passed to the script.</param>
/// <returns>The <see cref="Bitmap"/> that the script creates.</returns>
/// <exception cref="PythonRunnerException">
/// Thrown if error text was outputted by the script (this normally happens
/// if an exception was raised by the script). <br/>
/// -- or -- <br/>
/// An unexpected error happened during script execution. In this case, the
/// <see cref="Exception.InnerException"/> property contains the original
/// <see cref="Exception"/>.
/// </exception>
/// <exception cref="ArgumentNullException">
/// Argument <paramref name="script"/> is null.
/// </exception>
/// <exception cref="FileNotFoundException">
/// Argument <paramref name="script"/> is an invalid path.
/// </exception>
/// <remarks>
/// <para>
/// In a 'normal' case, a Python script that creates a chart would show this chart
/// with the help of Python's own backend, like this.
/// <example>
/// import matplotlib.pyplot as plt
/// ...
/// plt.show()
/// </example>
/// For the script to be used within the context of this <see cref="PythonRunner"/>,
/// it should instead convert the image to a base64-encoded string and print this string
/// to the console. The following code snippet shows a Python method (<c>print_figure</c>)
/// that does this:
/// <example>
/// import io, sys, base64
///
/// def print_figure(fig):
/// buf = io.BytesIO()
/// fig.savefig(buf, format='png')
/// print(base64.b64encode(buf.getbuffer()))
///
/// import matplotlib.pyplot as plt
/// ...
/// print_figure(plt.gcf()) # the gcf() method retrieves the current figure
/// </example>
/// </para><para>
/// Output to the error stream can also come from warnings, that are frequently
/// outputted by various python package components. These warnings would result
/// in an exception, therefore they must be switched off within the script by
/// including the following statement: <c>warnings.simplefilter("ignore")</c>.
/// </para>
/// </remarks>
public Bitmap GetImage(string script, params object[] arguments) { ... }
/// <summary>
/// Runs the <see cref="GetImage"/> method asynchronously.
/// </summary>
/// <returns>
/// An awaitable task, with the <see cref="Bitmap"/> that the script
/// creates as <see cref="Task{TResult}.Result"/>.
/// </returns>
/// <seealso cref="GetImage"/>
public Task<Bitmap> GetImageAsync(string script, params object[] arguments) { ... }
}
检索股票数据
如前所述,示例应用程序使用 SQLite 数据库作为其数据存储(Python 端也访问它,见下文)。为此,使用了 Entity Framework,并结合了在 此 CodeProject 文章中找到的方法。然后将股票数据放入 ListCollectionView,它支持过滤和排序。
private void LoadStocks()
{
var ctx = new SQLiteDatabaseContext(_mainVm.DbPath);
var itemList = ctx.Stocks.ToList().Select(s => new StockItem(s)).ToList();
_stocks = new ObservableCollection<StockItem>(itemList);
_collectionView = new ListCollectionView(_stocks);
// Initially sort the list by stock names
ICollectionView view = CollectionViewSource.GetDefaultView(_collectionView);
view.SortDescriptions.Add(new SortDescription("Name", ListSortDirection.Ascending));
}
获取文本输出
这里,PythonRunner
调用一个生成文本输出的脚本。KMeansClusteringScript
属性指向要执行的脚本。
/// <summary>
/// Calls the python script to retrieve a textual list that
/// will subsequently be used for building the treeview.
/// </summary>
/// <returns>True on success.</returns>
private async Task<string> RunKMeans()
{
TreeViewText = Processing;
Items.Clear();
try
{
string output = await _mainVm.PythonRunner.ExecuteAsync(
KMeansClusteringScript,
_mainVm.DbPath,
_mainVm.TickerList,
_mainVm.NumClusters,
_mainVm.StartDate.ToString("yyyy-MM-dd"),
_mainVm.EndDate.ToString("yyyy-MM-dd"));
return output;
}
catch (Exception e)
{
TreeViewText = e.ToString();
return string.Empty;
}
}
下面是脚本生成的一些示例文本输出
0 AYR 0,0,255
0 PCCWY 0,100,0
0 HSNGY 128,128,128
0 CRHKY 165,42,42
0 IBN 128,128,0
1 SRNN 199,21,133
...
4 PNBK 139,0,0
5 BOTJ 255,165,0
5 SPPJY 47,79,79
第一列是 k 均值分析的聚类编号,第二列是相应股票的股票代码,第三列是用于绘制图表中股票线条的颜色的 RGB 值。
获取图像
这是使用 ViewModel 的 PythonRunner
实例异步调用所需 Python 脚本(其路径存储在 DrawSummaryLineChartScript
属性中)的方法,以及所需的脚本参数。一旦可用,结果就会被处理成“WPF 友好”的形式。
/// <summary>
/// Calls the python script to draw the chart of the selected stocks.
/// </summary>
/// <returns>True on success.</returns>
internal async Task<bool> DrawChart()
{
SummaryChartText = Processing;
SummaryChart = null;
try
{
var bitmap = await _mainVm.PythonRunner.GetImageAsync(
DrawSummaryLineChartScript,
_mainVm.DbPath,
_mainVm.TickerList,
_mainVm.StartDate.ToString("yyyy-MM-dd"),
_mainVm.EndDate.ToString("yyyy-MM-dd"));
SummaryChart = Imaging.CreateBitmapSourceFromHBitmap(
bitmap.GetHbitmap(),
IntPtr.Zero,
Int32Rect.Empty,
BitmapSizeOptions.FromEmptyOptions());
return true;
}
catch (Exception e)
{
SummaryChartText = e.ToString();
return false;
}
}
Python 端
抑制警告
需要注意的一个重要问题是,PythonRunner
类会在所调用的脚本写入 stderr
时立即抛出异常。当 Python 代码由于某种原因引发错误时就会出现这种情况,在这种情况下,我们希望重新抛出错误。但是,当某个组件发出无害警告时,脚本也可能写入 stderr
,例如当某个东西很快被弃用,或者某个东西被初始化两次,或者任何其他小问题。在这种情况下,我们不希望中断执行,而只是忽略警告。下面代码片段中的语句正是这样做的。
import warnings
...
# Suppress all kinds of warnings (this would lead to an exception on the client side).
warnings.simplefilter("ignore")
...
解析命令行参数
正如我们所见,C#(客户端)侧使用可变数量的位置参数调用脚本。参数通过命令行传递给脚本。这意味着脚本“理解”这些参数并进行相应解析。传递给 Python 脚本的命令行参数可以通过 sys.argv
string
数组访问。下面的代码片段来自 kmeans.py
脚本,演示了如何做到这一点。
import sys
...
# parse command line arguments
db_path = sys.argv[1]
ticker_list = sys.argv[2]
clusters = int(sys.argv[3])
start_date = sys.argv[4]
end_date = sys.argv[5]
...
检索股票数据
Python 脚本使用与 C# 代码相同的 SQLite 数据库。这是通过将数据库路径存储在 C# 端 app.config 的应用程序设置中,然后将其作为参数传递给调用的 Python 脚本来实现的。上面我们已经看到了从调用方以及 Python 脚本中的命令行参数解析是如何完成的。现在这里是 Python 帮助函数,它从参数构建 SQL 语句,并将所需数据加载到 dataframe 数组中(使用 sqlalchemy Python 包)。
from sqlalchemy import create_engine
import pandas as pd
def load_stock_data(db, tickers, start_date, end_date):
"""
Loads the stock data for the specified ticker symbols, and for the specified date range.
:param db: Full path to database with stock data.
:param tickers: A list with ticker symbols.
:param start_date: The start date.
:param end_date: The start date.
:return: A list of time-indexed dataframe, one for each ticker, ordered by date.
"""
SQL = "SELECT * FROM Quotes WHERE TICKER IN ({}) AND Date >= '{}' AND Date <= '{}'"\
.format(tickers, start_date, end_date)
engine = create_engine('sqlite:///' + db)
df_all = pd.read_sql(SQL, engine, index_col='Date', parse_dates='Date')
df_all = df_all.round(2)
result = []
for ticker in tickers.split(","):
df_ticker = df_all.query("Ticker == " + ticker)
result.append(df_ticker)
return result
文本输出
对于 Python 脚本来说,生成 C# 端可消费的文本输出很简单:像往常一样打印到控制台。调用的 PythonRunner
类将负责其他所有事情。下面是 kmeans.py 中生成上面文本的片段。
# Create a DataFrame aligning labels and companies.
df = pd.DataFrame({'ticker': tickers}, index=labels)
df.sort_index(inplace=True)
# Make a real python list.
ticker_list = list(ticker_list.replace("'", "").split(','))
# Output the clusters together with the used colors
for cluster, row in df.iterrows():
ticker = row['ticker']
index = ticker_list.index(ticker)
rgb = get_rgb(common.COLOR_MAP[index])
print(cluster, ticker, rgb)
图像输出
图像输出与文本输出非常相似:首先,脚本像往常一样创建所需的图形。然后,不是调用 show()
方法使用 Python 的自身后端显示图像,而是将其转换为 base64 字符串
并将其打印到控制台。您可以使用此帮助函数。
import io, sys, base64
def print_figure(fig):
"""
Converts a figure (as created e.g. with matplotlib or seaborn) to a png image and this
png subsequently to a base64-string, then prints the resulting string to the console.
"""
buf = io.BytesIO()
fig.savefig(buf, format='png')
print(base64.b64encode(buf.getbuffer()))
在您的主脚本中,然后像这样调用帮助函数(gcf()
函数仅获取当前图形)。
import matplotlib.pyplot as plt
...
# do stuff
...
print_figure(plt.gcf())
然后,在 C# 客户端端,PythonRunner
使用的这个小帮助类会将此 string
转换回 Image(具体来说是 Bitmap)。
/// <summary>
/// Helper class for converting a base64 string (as printed by
/// python script) to a <see cref="Bitmap" /> image.
/// </summary>
internal static class PythonBase64ImageConverter
{
/// <summary>
/// Converts a base64 string (as printed by python script) to a <see cref="Bitmap" /> image.
/// </summary>
public static Bitmap FromPythonBase64String(string pythonBase64String)
{
// Remove the first two chars and the last one.
// First one is 'b' (python format sign), others are quote signs.
string base64String = pythonBase64String.Substring(2, pythonBase64String.Length - 3);
// Convert now raw base46 string to byte array.
byte[] imageBytes = Convert.FromBase64String(base64String);
// Read bytes as stream.
var memoryStream = new MemoryStream(imageBytes, 0, imageBytes.Length);
memoryStream.Write(imageBytes, 0, imageBytes.Length);
// Create bitmap from stream.
return (Bitmap)Image.FromStream(memoryStream, true);
}
}
历史
- 2019 年 8 月 24 日:初始版本