TowardsDataScience-2023-博客中文翻译-十五-
TowardsDataScience 2023 博客中文翻译(十五)
使用 Streamlit 部署您的时间序列预测模型
原文:
towardsdatascience.com/deploy-your-time-series-forecasting-model-with-streamlit-c3ce5a7edf19
一份关于使用 Python 构建 Web 应用程序以部署预测模型的实用指南
·发布于数据科学之路 ·13 分钟阅读·2023 年 4 月 25 日
--

作为数据科学家,我们经常处于实验阶段。我们在笔记本中工作,开发脚本以评估模型,然后就停在那里。
然而,我们的工作永远不会真正完成,直到模型被部署。这个关键步骤带来了新的挑战,因为我们必须考虑错误处理和构建与模型互动的接口。
这就是Streamlit的用武之地:它是一个 Python 库,允许我们快速构建数据应用程序,因为我们只需使用 Python 来进行建模部分和构建用户界面。
尽管 Streamlit 是一个很好的原型工具,可以快速部署模型,但它可能无法支持高流量的全面数据应用程序。你应该将其视为为模型添加互动性并与他人分享数据科学工作的方式。
在这篇文章中,我们将探讨使用 Streamlit 部署时间序列模型的部分内容。完整项目是一个多页面应用程序,我们可以在其中探索和预测数据,但在这篇文章中,我们只关注预测功能。
你可以访问完成的应用程序并试用它。此外,完整的源代码可在GitHub上获取。
通过我的 免费时间序列备忘单 在 Python 中学习最新的时间序列分析技术!获取统计和深度学习技术的实现,全部使用 Python 和 TensorFlow!
让我们开始吧!
目标
该应用程序的目标是预测加拿大季度人口。数据集取自加拿大统计局,涵盖了 1991 年第三季度到 2023 年第一季度。原始数据可以在这里找到。
以下是数据集的一个样本。

应用程序中使用的数据集样本。对于每个位置,我们有季度人口估算。图像由作者提供。
使用这个数据集,我们可以获取整个国家的总人口信息,也可以获取每个省份和地区的人口信息。这意味着我们可以在多个目标之间进行选择,在构建应用程序时必须牢记这一点。
项目结构
由于我们正在构建一个将部署到生产环境的应用程序,因此项目的结构非常重要。以下是我们为此项目使用的结构。
streamlit-population-canada
├── data
│ └── quarterly_canada_population.csv
├── pages
│ └── forecast.py
├── main.py
├── README.md
└── requirements.txt
在我们的项目文件夹中,我们有一个数据文件夹,其中包含包含季度人口估算的 CSV 文件。请注意,我们将从 GitHub URL 读取数据,以避免处理相对和绝对文件路径的问题。
然后,因为我们正在构建一个多页面应用程序,我们有一个pages文件夹,其中包含构建界面和输出预测的脚本,即forecast.py。
应用程序的主要入口点由文件main.py控制,但我们不会涉及它,因为它与预测无关。
最终,文件requirements.txt列出了应用程序正常运行所需的所有依赖项。
应用程序图
在深入代码之前,我们必须首先确定应用程序的高层次工作方式,以了解每个功能的作用。

应用程序图。图像由作者提供。
从上图中,我们可以看到数据需要被读取并在脚本中提供。
然后,我们添加了一个选项,供用户选择目标和时间范围。我们将这些信息发送到一个函数,该函数将测试不同的模型,并仅使用最佳模型生成预测。
然后,返回预测结果,以便我们可以绘制预测图。同时,我们返回评估指标,以便我们可以可视化它并确定使用了哪个模型。
在有了正确的项目结构和整体构建思路之后,我们现在可以深入代码,开始构建我们的应用程序。
读取数据
第一步自然是读取数据。现在,由于这是多页面应用程序的一部分,我们实际上在main.py中读取数据,并在pages/forecast.py中提供数据。
我们首先定义一个读取数据的函数。注意我们从 URL 读取,这样在部署应用程序时可以避免文件路径的问题。现在,这只是标准代码,用于在笔记本中读取数据。
import streamlit as st
import pandas as pd
import numpy as np
@st.cache_data
def read_data():
URL = "https://raw.githubusercontent.com/marcopeix/streamlit-population-canada/master/data/quarterly_canada_population.csv"
df = pd.read_csv(URL, index_col=0, dtype={'Quarter': str,
'Canada': np.int32,
'Newfoundland and Labrador': np.int32,
'Prince Edward Island': np.int32,
'Nova Scotia': np.int32,
'New Brunswick': np.int32,
'Quebec': np.int32,
'Ontario': np.int32,
'Manitoba': np.int32,
'Saskatchewan': np.int32,
'Alberta': np.int32,
'British Columbia': np.int32,
'Yukon': np.int32,
'Northwest Territories': np.int32,
'Nunavut': np.int32})
return df
同时注意装饰器@st.cache_data的使用。它告诉 Streamlit 如果参数不变就缓存此函数的结果。这样,用户每次更改输入时,我们就不必重新运行函数。这使得应用程序运行得更快。
然后,我们将数据存储在会话状态中。这是一种在 Streamlit 的多个页面之间存储和持久化数据的方法。这样,我们可以在main.py中读取数据,并在pages/forecast.py中访问这些数据。
df = read_data()
if df not in st.session_state:
st.session_state['df'] = df
它本质上充当一个字典,我们可以在其中存储我们希望从不同页面访问的重要值。
这样,在pages/forecast.py中,我们可以通过
df = st.session_state['df']
即使我们在另一个文件中,我们也不必重复读取数据的函数,我们可以通过会话状态直接访问它。
构建界面
现在我们可以访问数据了,让我们构建界面以允许用户选择目标和预测期。
首先,让我们允许用户选择一个目标。在我们的数据集中,目标对应于一个位置,无论是整个国家、省份还是地区。如果你还记得上面显示的数据集示例,可能的目标列表就是数据集中的列名。
所以,让我们创建两个列,以便用户可以在同一行上选择目标和预测期。
import streamlit as st
st.title('Forecast the Quarterly Population in Canada')
col1, col2 = st.columns(2)
然后,在第一个列中,我们将创建一个下拉列表,其中包含数据集中所有可能的目标。
target = col1.selectbox('Select your target', df.columns)
在上面的代码块中,第一个参数是输入的标签,然后我们传递可能值的列表。默认情况下,列表中的第一个值将被选中。
然后,为了设置预测期,让我们使用滑块。在这里,我将预测期限制在未来 1 到 16 个季度之间,但你可以随意调整这些数字。
horizon = col2.slider('Choose the horizon', min_value=1, max_value=16, value=1, step=1)
如你所见,上面的代码非常直观:我们传入输入元素的标签,指定最小值、最大值、默认值,以及增量步骤。
目前,界面看起来是这样的:

迄今为止的应用程序界面。图片由作者提供。
很棒!
现在,让我们添加一个按钮,这样只有当用户设置了目标和预测期后,我们才运行函数以返回预测结果。否则,Streamlit 会在每次检测到变化时运行整个脚本。
forecast_btn = st.button('Forecast')
然后我们得到这个:

和以前一样,不过添加了一个按钮。图片由作者提供。
好了,界面已经完成,现在让我们编写逻辑来选择最佳模型并生成预测结果。
生成预测
一旦用户设置了目标和预测期,我们希望测试不同的模型,以便根据目标和预测期获得最佳的预测结果。
然后,绘制预测结果并显示评估指标以证明模型选择是有用的。这是我们在本节中实现的功能。
模型选择过程
对于这个应用程序,我们将测试三种不同的模型:
详细介绍每个模型会太长,因此如果您想详细了解每个模型,我已链接到其他文章。
现在,对于每个模型,我们将在训练集上训练它,并在测试集上进行评估。测试集将包含数据的最后 32 个时间步长,其余部分将用于训练。
此外,我们将进行滚动预测,例如模拟使用新数据更新模型并进行新预测的过程。这意味着如果时间范围设置为 2 个时间步长,那么模型将一次预测 2 个未来时间步长,直到预测完所有测试集,如下所示。

可视化滚动预测。模型在初始训练集上进行训练并进行预测。然后,更新训练集,模型进行另一组预测,直到覆盖整个测试集。图片由作者提供。
所有这些逻辑都转化为下面的代码块。
@st.cache_data
def rolling_predictions(df, train_len, horizon, window, method):
TOTAL_LEN = len(df)
if method == 'ar':
best_lags = ar_select_order(df[:train_len], maxlag=8, glob=True).ar_lags
pred_AR = []
for i in range(train_len, TOTAL_LEN, window):
ar_model = AutoReg(df[:i], lags=best_lags)
res = ar_model.fit()
predictions = res.predict(i, i + window -1)
oos_pred = predictions[-window:]
pred_AR.extend(oos_pred)
return pred_AR[:horizon]
elif method == 'holt':
pred_holt = []
for i in range(train_len, TOTAL_LEN, window):
des = Holt(df[:i], initialization_method='estimated').fit()
predictions = des.forecast(window)
pred_holt.extend(predictions)
return pred_holt[:horizon]
elif method == 'theta':
pred_theta = []
for i in range(train_len, TOTAL_LEN, window):
tm = ThetaModel(endog=df[:i], deseasonalize=False)
res = tm.fit()
preds = res.forecast(window)
pred_theta.extend(preds)
return pred_theta[:horizon]
在上面的代码块中,我们简单地对每个模型进行训练,使用初始训练集,并通过更新训练集并进行预测,直到我们对整个测试集有预测结果。
评估指标
为了选择最佳模型,我们使用对称平均绝对百分比误差或 sMAPE。原因是 MAPE 倾向于偏向 低估 的模型。sMAPE 修复了这个问题,其定义为:

sMAPE 的公式。图片由作者提供。
因此,让我们定义一个计算 sMAPE 的函数:
def smape(actual, predicted):
if not all([isinstance(actual, np.ndarray), isinstance(predicted, np.ndarray)]):
actual, predicted = np.array(actual), np.array(predicted)
return round(np.mean(np.abs(predicted - actual) / ((np.abs(predicted) + np.abs(actual))/2))*100, 2)
选择最佳模型
让我们开始编写运行模型选择过程的函数。它接受数据集、目标和时间范围,我们首先定义训练集和测试集。
@st.cache_data
def test_and_predict(df, col_name, horizon):
df = df.reset_index()
model_list = ['ar', 'holt', 'theta']
train = df[col_name][:-32]
test = df[['Quarter', col_name]][-32:]
total_len = len(df)
train_len = len(train)
test_len = len(test)
接着,我们使用我们的 rolling_predictions 函数在测试集上生成预测。
pred_AR = rolling_predictions(df[col_name], train_len, test_len, horizon, 'ar')
pred_holt = rolling_predictions(df[col_name], train_len, test_len, horizon, 'holt')
pred_theta = rolling_predictions(df[col_name], train_len, test_len, horizon, 'theta')
test['pred_AR'] = pred_AR
test['pred_holt'] = pred_holt
test['pred_theta'] = pred_theta
现在,我们已经拥有整个测试集的所有预测结果,我们可以使用 sMAPE 来评估每个模型。
smapes = []
smapes.append(smape(test[col_name], test['pred_AR']))
smapes.append(smape(test[col_name], test['pred_holt']))
smapes.append(smape(test[col_name], test['pred_theta']))
然后,最佳模型就是获得最低 sMAPE 的模型。
best_model = model_list[np.argmin(smapes)]
然后,我们可以简单地使用最佳模型来获取预测结果。
进行预测
根据哪个模型获得了最低的 sMAPE,我们将对其进行全面数据集训练,以生成样本外预测并实际预测未来。
这可以通过简单的 if 语句完成。
if best_model == 'ar':
best_lags = ar_select_order(train, maxlag=8, glob=True).ar_lags
ar_model = AutoReg(df[col_name], lags=best_lags)
res = ar_model.fit()
predictions = res.predict(total_len, total_len + horizon - 1)
return predictions, smapes
elif best_model == 'holt':
des = Holt(df[col_name], initialization_method='estimated').fit()
predictions = des.forecast(horizon)
return predictions, smapes
elif best_model == 'theta':
tm = ThetaModel(endog=df[col_name], deseasonalize=False)
res = tm.fit()
predictions = res.forecast(horizon)
然后,我们的 test_and_predict 函数将返回每个模型的预测结果和 sMAPE。这样,我们可以可视化每个模型的预测和评估指标。
完整的函数如下所示。
@st.cache_data
def test_and_predict(df, col_name, horizon):
df = df.reset_index()
model_list = ['ar', 'holt', 'theta']
train = df[col_name][:-32]
test = df[['Quarter', col_name]][-32:]
total_len = len(df)
train_len = len(train)
test_len = len(test)
pred_AR = rolling_predictions(df[col_name], train_len, test_len, horizon, 'ar')
pred_holt = rolling_predictions(df[col_name], train_len, test_len, horizon, 'holt')
pred_theta = rolling_predictions(df[col_name], train_len, test_len, horizon, 'theta')
test['pred_AR'] = pred_AR
test['pred_holt'] = pred_holt
test['pred_theta'] = pred_theta
smapes = []
smapes.append(smape(test[col_name], test['pred_AR']))
smapes.append(smape(test[col_name], test['pred_holt']))
smapes.append(smape(test[col_name], test['pred_theta']))
best_model = model_list[np.argmin(smapes)]
if best_model == 'ar':
best_lags = ar_select_order(train, maxlag=8, glob=True).ar_lags
ar_model = AutoReg(df[col_name], lags=best_lags)
res = ar_model.fit()
predictions = res.predict(total_len, total_len + horizon - 1)
return predictions, smapes
elif best_model == 'holt':
des = Holt(df[col_name], initialization_method='estimated').fit()
predictions = des.forecast(horizon)
return predictions, smapes
elif best_model == 'theta':
tm = ThetaModel(endog=df[col_name], deseasonalize=False)
res = tm.fit()
predictions = res.forecast(horizon)
return predictions, smapes
点击按钮时获取预测
我们已经有了界面,并且有了测试和生成预测的所有逻辑。现在,我们只需要将它们连接起来。
我们可以通过 if 语句检测按钮是否被点击,因为 Streamlit 在按钮被点击时返回 True。
if forecast_btn:
preds, smapes = test_and_predict(df, target, horizon)
请注意,在上面的代码块中,target 和 horizon 是本文中早些时候定义的变量,它们分别捕获了用户通过下拉菜单和滑块设置的信息。
然后,在 if 语句内部,我们生成两个图:一个用于预测,另一个用于评估指标。
在 Streamlit 中绘图就像在 Jupyter notebook 中使用 matplotlib 一样。我们只是使用 pyplot 方法渲染图表。
tab1, tab2 = st.tabs(['Predictions', 'Model evaluation'])
pred_fig, pred_ax = plt.subplots()
pred_ax.plot(df[target])
pred_ax.plot(preds, label='Forecast')
pred_ax.set_xlabel('Time')
pred_ax.set_ylabel('Population')
pred_ax.legend(loc=2)
pred_ax.set_xticks(np.arange(2, len(df) + len(preds), 8))
pred_ax.set_xticklabels(np.arange(1992, 2024 + floor(len(preds)/4), 2))
pred_fig.autofmt_xdate()
tab1.pyplot(pred_fig)
上面代码块的结果如下:

显示最佳模型的预测结果。图片由作者提供。
如你所见,用户选择了加拿大作为目标,并选择了 16 个季度的预测期。点击按钮后,图表被渲染出来。
然后,让我们绘制一个条形图,以查看哪个模型在测试过程中表现最佳。
eval_fig, eval_ax = plt.subplots()
x = ['AR', 'DES', 'Theta']
y = smapes
eval_ax.bar(x, y, width=0.4)
eval_ax.set_xlabel('Models')
eval_ax.set_ylabel('sMAPE')
eval_ax.set_ylim(0, max(smapes)+0.1)
for index, value in enumerate(y):
plt.text(x=index, y=value + 0.015, s=str(round(value,2)), ha='center')
tab2.pyplot(eval_fig)
这给出如下结果:

显示每个模型的 sMAPE。在这里,AR 模型在测试中实现了最低的 sMAPE,用于预测未来 16 个季度的加拿大人口。图片由作者提供。
从上图中,我们看到 AR 模型是最佳模型,因为它实现了最低的 sMAPE,特别是在预测未来 16 个季度的加拿大季度人口时。因此,AR 模型被用来进行我们在上图中看到的超出样本的预测。
太棒了,一切在本地机器上都运作良好,现在让我们实际部署它,并与全世界分享吧!
部署到生产环境
部署 Streamlit 应用程序最简单(且免费的)方式是使用 Streamlit Community Cloud。
要部署一个应用程序,你需要:
-
一个用于托管应用程序源代码的 GitHub 帐户
-
一个 Streamlit Community Cloud 帐户(免费)
-
一个
requirements.txt文件来列出依赖项
列出依赖项
部署 Streamlit 应用程序最困难的部分实际上是列出依赖项,特别是如果你在 Windows 机器上使用 Anaconda(像我一样)。
如果你使用的是 Linux,那么 pip freeze > requirements.txt 就非常简单。
在撰写时,Streamlit 的文档表明他们支持可以通过 Anaconda 生成的 environment.yml 文件,但我发现这会破坏部署过程。所以,你需要使用 .txt 文件。
使用 Anaconda,运行 conda list -e > requirements.txt。然而,你的文件将无法正确格式化,并且会有很多错误,需要你手动修复。
解决这个问题的一个简单方法是去除脚本开头未实际导入的所有包。在我们的案例中,这些包是:
-
pandas
-
numpy
-
matplotlib
-
statsmodels
文件中的其他内容可以删除。
同时,删除streamlit依赖项非常重要,因为这会破坏部署过程。你可以查看你的文件应该是什么样子的这里。
上线生产
一旦列出了依赖项,请确保所有代码都在 GitHub 仓库中。
然后,在你的 Streamlit Community Cloud 账户中,点击“新应用”。

Streamlit Community Cloud 界面。图片由作者提供。
然后,只需指定仓库的 URL、分支(应为 master)和主文件路径。通常,项目根目录下有一个主 Python 脚本。

部署 Streamlit 应用程序的表单。图片由作者提供。
然后,只需点击“部署”,就完成了!你将获得一个链接,可以用来与大家分享你的应用!
如果你想查看最终的完整结果,可以在这里查看应用!
结论
使用 Streamlit 部署模型直观且简单。仅使用 Python,我们创建了一个交互式 Web 界面,可以缓存数据、读取用户输入、运行模型并生成未来的预测!
我希望这篇文章能激励你进一步探究 Streamlit,并制作你自己的小应用!
如果你想掌握时间序列预测,那么查看我的课程:Python 中的应用时间序列预测。这是唯一一个在 15 个指导项目中实现统计学、深度学习和最先进模型的课程。
干杯 🍻
支持我
喜欢我的工作吗?通过买杯咖啡来支持我,这是鼓励我的一种简单方式,而我可以享受一杯咖啡!如果你愿意,只需点击下面的按钮 👇
在 Amazon SageMaker 上部署 Cohere 语言模型
原文:
towardsdatascience.com/deploying-cohere-language-models-on-amazon-sagemaker-23a3f79639b1
在 AWS 上扩展和托管 LLMs
·发布于 Towards Data Science ·阅读时间 7 分钟·2023 年 5 月 18 日
--

大型语言模型(LLMs)和生成型人工智能正在加速各行各业的机器学习增长。LLMs 使机器学习的范围达到了惊人的高度,但也带来了新的一系列挑战。
大型语言模型(LLMs)的规模在机器学习生命周期的训练和托管部分都带来了困难的问题。尤其是在 LLMs 的托管方面,有许多挑战需要考虑。我们如何将模型适配到单个 GPU 上进行推理?我们如何在不妨碍准确性的情况下应用模型压缩和分割技术?我们如何提高这些 LLMs 的推理延迟和吞吐量?
要解决这些问题,需要高级机器学习工程技术,我们必须在一个可以在容器和硬件层面应用压缩和并行化技术的平台上协调模型托管。有像 DJL Serving 这样的解决方案提供 适配于 LLM 托管的容器,但我们在本文中不会探讨这些。
在本文中,我们将探讨SageMaker JumpStart 基础模型。使用基础模型时,我们无需担心容器或模型并行化和压缩技术,而是主要关注直接部署预训练模型和选择硬件。特别是本文中,我们将探讨一个名为Cohere的热门 LLM 提供商,以及如何在 SageMaker 上托管其流行的语言模型进行推理。
注意:对于新接触 AWS 的用户,如果你想跟随教程,确保在以下链接上创建一个账户。本文还假设你对 SageMaker 部署有一定的了解,我建议你参考这篇文章以更深入地理解部署/推理。特别是对于 SageMaker JumpStart,我建议参考这篇博客。
什么是 SageMaker JumpStart?什么是基础模型?
SageMaker JumpStart 本质上是 SageMaker 的模型库。在这里有各种不同的预训练模型,这些模型已经容器化并可以通过 SageMaker Python SDK 部署。主要的价值在于,客户无需担心调整或配置容器来托管特定模型,这些繁重的工作已经处理好了。
针对大型语言模型(LLMs),JumpStart 基础模型推出了来自各种提供商的热门语言模型,如 Stability AI 和 Cohere。在 SageMaker 控制台上,你可以查看所有可用的基础模型的完整列表。

SageMaker JumpStart 基础模型(作者截图)
这些基础模型也可以通过AWS MarketPlace访问,你可以订阅那些默认情况下可能无法访问的特定模型。在我们将使用的 Cohere Medium 模型的情况下,这应该可以通过 JumpStart 无需任何订阅即可访问,但如果遇到任何问题,你可以通过以下链接申请访问。
Cohere Medium 语言模型部署
在这个示例中,我们将特别探讨如何通过 SageMaker JumpStart 部署 Cohere 的 GPT Medium 语言模型。在开始之前,我们需要安装 cohere-sagemaker SDK。这个 SDK 进一步简化了部署过程,因为它在通常的 SageMaker 推理构造(SageMaker 模型、SageMaker 端点配置和 SageMaker 端点)之上构建了一个包装器。
!pip install cohere-sagemaker --quiet
从这个 SDK 中,我们导入了 Client 对象,它将帮助我们创建端点并进行推理。
from cohere_sagemaker import Client
import boto3
如果我们访问 Marketplace 链接,会看到这个模型通过 Model Package 提供。因此,下一步我们提供 Cohere Medium 模型的 Model Package ARN。请注意,这个特定模型目前仅在 US-East-1 和 EU-West-1 区域可用。
# Currently us-east-1 and eu-west-1 only supported
model_package_map = {
"us-east-1": "arn:aws:sagemaker:us-east-1:865070037744:model-package/cohere-gpt-medium-v1-5-15e34931a06235b7bac32dca396a970a",
"eu-west-1": "arn:aws:sagemaker:eu-west-1:985815980388:model-package/cohere-gpt-medium-v1-5-15e34931a06235b7bac32dca396a970a",
}
region = boto3.Session().region_name
if region not in model_package_map.keys():
raise Exception(f"Current boto3 session region {region} is not supported.")
model_package_arn = model_package_map[region]
现在我们有了模型包,我们可以实例化我们的 Client 对象并创建端点。使用 JumpStart,我们需要提供模型包详细信息、实例类型和数量,以及端点名称。
# instantiate client
co = Client(region_name=region)
co.create_endpoint(arn=model_package_arn, endpoint_name="cohere-gpt-medium",
instance_type="ml.g5.xlarge", n_instances=1)
对于像 Cohere 这样的语言模型,我们推荐主要使用基于 GPU 的实例类型,例如 g5 系列、p3/p2 系列和 g4dn 实例类。这些实例都有足够的计算和内存来处理这些模型的大小。有关进一步的指导,您还可以参考 MarketPlace 对您选择的特定模型所建议的实例。
接下来,我们使用 generate API 调用进行样例推理,该调用将为我们提供的提示创建文本。这个 generate API 调用作为 Cohere 对 invoke_endpoint API 调用的封装,后者通常用于 SageMaker 端点。
prompt = "Write a LinkedIn post about starting a career in tech:"
# API Call
response = co.generate(prompt=prompt, max_tokens=100, temperature=0, return_likelihoods='GENERATION')
print(response.generations[0].text)

样例推理(作者截图)
参数调整
要深入了解可以调整的不同 LLM 参数,我会参考 Cohere 的官方文章 这里。我们主要关注于调整在 generate API 调用中看到的两个不同参数。
-
max_tokens:正如词语所示,最大 token 数量是我们 LLM 可以生成的 token 数量限制。LLM 定义的 token 可能有所不同,可以是字符、词、短语或更多。Cohere 使用字节对编码来定义他们的 tokens。要完全理解他们的模型如何定义 token,请参考以下文档。实际上,我们可以在此参数上进行迭代,以找到一个最佳值,因为我们不希望一个太小的值,因为它不能很好地回答我们的提示,也不希望一个太大的值,以至于响应没有多大意义。Cohere 的生成模型支持最多 2048 个 tokens。
-
温度:温度参数帮助控制模型的“创造力”。例如,当生成一个词时,会有一个词的列表,其中每个词有不同的概率用于下一个词。当温度参数较低时,模型倾向于选择概率最高的词。当我们增加温度时,响应往往会有较大的多样性,因为模型开始选择概率较低的词。对于这个模型,该参数的范围是 0 到 5。
首先,我们可以探索在max_token大小上的迭代。我们创建一个包含 5 个任意 token 大小的数组,并在保持温度不变的情况下循环进行推断。
token_range = [100, 200, 300, 400, 500]
for token in token_range:
response = co.generate(prompt=prompt, max_tokens=token, temperature=0.9, return_likelihoods='GENERATION')
print("-----------------------------------")
print(response.generations[0].text)
print("-----------------------------------")
正如预期的那样,我们可以看到每个响应的长度差异。

Token Size 200(作者截图)

Token Size 300(作者截图)
我们还可以通过遍历 0 到 5 之间的值来测试温度参数。
for i in range(5):
response = co.generate(prompt=prompt, max_tokens=100, temperature=i, return_likelihoods='GENERATION')
print("-----------------------------------")
print(response.generations[0].text)
print("-----------------------------------")
我们可以看到,在值为 1 时,我们获得了一个非常现实的输出,大部分情况下是有意义的。

温度 1(作者截图)
在温度为 5 时,我们看到的输出虽然有一定的意义,但由于词汇选择,偏离主题极为严重。

温度 5(作者截图)
如果你想测试这些参数的所有不同组合以找到最佳配置,你也可以运行以下代码块。
import itertools
# Create array of all combinations of both params
temperature = [0,1,2,3,4,5]
params = [token_range, temperature]
param_combos = list(itertools.product(*params))
for param in param_combos:
response = co.generate(prompt=prompt, max_tokens=param[0],
temperature=param[1], return_likelihoods='GENERATION')
额外资源与结论
[## SageMaker-Deployment/cohere-medium.ipynb at master · RamVegiraju/SageMaker-Deployment
你现在无法执行该操作。你在另一个标签或窗口中登录了。你在另一个标签或窗口中注销了…
整个示例的代码可以在上面的链接中找到(请继续关注更多 LLM 和 JumpStart 示例)。借助 SageMaker JumpStart 的基础模型,可以轻松通过 API 调用来托管 LLM,而无需处理容器化和模型服务的繁琐工作。希望这篇文章对 LLM 与 Amazon SageMaker 的介绍有所帮助,随时欢迎留下反馈或提问。
如果你喜欢这篇文章,请随时通过 LinkedIn 与我联系,并订阅我的 Medium Newsletter。如果你是 Medium 的新用户,可以使用我的 Membership Referral进行注册。
部署 Falcon-7B 进入生产环境
一步步教程
在云端以微服务形式运行 Falcon-7B
·
关注 发表在 Towards Data Science ·16 分钟阅读·2023 年 7 月 7 日
--
图片由作者提供-使用 Midjourney 创建
背景
到目前为止,我们已经了解了 ChatGPT 的能力及其提供的功能。然而,对于企业使用,像 ChatGPT 这样的闭源模型可能存在风险,因为企业无法控制其数据。OpenAI 声称用户数据不会被存储或用于训练模型,但无法保证数据不会以某种方式泄露。
为了应对与闭源模型相关的一些问题,研究人员正急于构建与 ChatGPT 等模型相媲美的开源 LLM。使用开源模型,企业可以在安全的云环境中自行托管这些模型,从而降低数据泄露的风险。此外,你还可以完全透明地了解模型的内部工作,这有助于建立对 AI 的更多信任。
随着开源 LLM 的最新进展,试用新的模型并查看它们如何与像 ChatGPT 这样的闭源模型竞争变得很有诱惑力。
然而,今天运行开源模型存在显著的障碍。调用 ChatGPT API 要比弄清楚如何运行开源 LLM 容易得多。
在这篇文章中,我的目标是通过展示如何在生产环境中运行像 Falcon-7B 这样的开源模型,来打破这些障碍。我们将能够通过类似于 ChatGPT 的 API 端点访问这些模型。
挑战
运行开源模型的一个重大挑战是缺乏计算资源。即使是像 Falcon 7B 这样的“小型”模型,也需要 GPU 才能运行。
为了解决这个问题,我们可以利用云中的 GPU。但这带来了另一个挑战。我们如何容器化我们的 LLM?我们如何启用 GPU 支持?启用 GPU 支持可能很棘手,因为这需要了解 CUDA。处理 CUDA 可能会令人头痛,因为你需要弄清楚如何安装正确的 CUDA 依赖以及哪些版本是兼容的。
因此,为了避免 CUDA 死循环,许多公司已经创建了解决方案来轻松容器化模型,同时支持 GPU。对于这篇博客文章,我们将使用一个名为Truss的开源工具来帮助我们轻松容器化我们的 LLM,而不费多少劲。
Truss 允许开发者轻松容器化使用任何框架构建的模型。
为什么使用 Truss?

Truss — truss.baseten.co/e2e
Truss 拥有许多开箱即用的有用功能,例如:
-
将你的 Python 模型转换为具有生产就绪 API 端点的微服务
-
通过 Docker 冻结依赖
-
支持 GPU 上的推理
-
模型的简单预处理和后处理
-
简单且安全的秘密管理
我之前使用过 Truss 来部署机器学习模型,过程非常顺利和简单。Truss 自动创建你的 dockerfile 并管理 Python 依赖。我们要做的就是提供我们的模型代码。

我们希望使用像 Truss 这样的工具的主要原因是,它使得部署支持 GPU 的模型变得更加容易。
注意:我没有收到 Baseten 的赞助来推广他们的内容,也没有与他们有任何关联。我没有受到 Baseten 或 Truss 任何形式的影响来撰写这篇文章。我只是发现他们的开源项目很酷且有用。
计划
在这篇博客中,我将涵盖以下内容:
-
使用 Truss 在本地设置 Falcon 7B
-
如果你有 GPU(我有一块 RTX 3080),可以在本地运行模型
-
将模型容器化并使用 docker 运行
-
在 Google Cloud 上创建一个启用 GPU 的 Kubernetes 集群来运行我们的模型
如果你在第二步没有 GPU,也不用担心,你仍然可以在云端运行模型。
如果你想跟随,请查看包含代码的 Github 仓库:
## GitHub - htrivedi99/falcon-7b-truss
通过在 GitHub 上创建账户,贡献于 htrivedi99/falcon-7b-truss 的开发。
让我们开始吧!
第一步:使用 Truss 本地设置 Falcon 7B
首先,我们需要创建一个 Python 版本 ≥ 3.8 的项目
我们将从 hugging face 下载模型,并使用 Truss 打包它。以下是我们需要安装的依赖项:
pip install truss
在你的 Python 项目中创建一个名为 main.py 的脚本。这是我们将用于与 truss 进行交互的临时脚本。
接下来,我们将在终端中运行以下命令来设置 Truss 包:
truss init falcon_7b_truss
如果你被提示创建一个新的 truss,按下‘y’。完成后,你应该会看到一个名为 falcon_7b_truss 的新目录。在该目录内,将有一些自动生成的文件和文件夹。我们需要填写几样东西:model.py,它嵌套在包 model 下,以及 config.yaml。
├── falcon_7b_truss
│ ├── config.yaml
│ ├── data
│ ├── examples.yaml
│ ├── model
│ │ ├── __init__.py
│ │ └── model.py
│ └── packages
└── main.py
如我之前提到的,Truss 只需要我们模型的代码,它会自动处理所有其他事情。我们将在 model.py 内编写代码,但必须按照特定格式编写。
Truss 期望每个模型支持至少三个函数:__init__ 、load 和 predict。
-
__init__主要用于创建类变量 -
load是我们将从 hugging face 下载模型的地方 -
predict是我们调用模型的地方
这是 model.py 的完整代码:
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
from typing import Dict
MODEL_NAME = "tiiuae/falcon-7b-instruct"
DEFAULT_MAX_LENGTH = 128
class Model:
def __init__(self, data_dir: str, config: Dict, **kwargs) -> None:
self._data_dir = data_dir
self._config = config
self.device = "cuda" if torch.cuda.is_available() else "cpu"
print("THE DEVICE INFERENCE IS RUNNING ON IS: ", self.device)
self.tokenizer = None
self.pipeline = None
def load(self):
self.tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model_8bit = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
device_map="auto",
load_in_8bit=True,
trust_remote_code=True)
self.pipeline = pipeline(
"text-generation",
model=model_8bit,
tokenizer=self.tokenizer,
torch_dtype=torch.bfloat16,
trust_remote_code=True,
device_map="auto",
)
def predict(self, request: Dict) -> Dict:
with torch.no_grad():
try:
prompt = request.pop("prompt")
data = self.pipeline(
prompt,
eos_token_id=self.tokenizer.eos_token_id,
max_length=DEFAULT_MAX_LENGTH,
**request
)[0]
return {"data": data}
except Exception as exc:
return {"status": "error", "data": None, "message": str(exc)}
这里发生了什么:
-
MODEL_NAME是我们将使用的模型,在我们的情况下是falcon-7b-instruct模型 -
在
load内,我们以 8bit 下载 hugging face 上的模型。我们选择 8bit 的原因是,当量化时,模型在 GPU 上占用的内存显著减少。 -
如果你想在 VRAM 少于 13GB 的 GPU 上本地运行模型,则加载 8-bit 模型是必要的。
-
predict函数接受一个 JSON 请求作为参数,并使用self.pipeline调用模型。torch.no_grad告诉 Pytorch 我们处于推理模式,而非训练模式。
太棒了!这就是我们设置模型所需的一切。
第 2 步:在本地运行模型(可选)
如果你有一块超过 8GB VRAM 的 Nvidia GPU,你将能够在本地运行这个模型。
如果没有,请随意继续下一步。
我们需要下载一些更多的依赖项以在本地运行模型。在下载依赖项之前,你需要确保已安装 CUDA 和正确的 CUDA 驱动程序。
由于我们尝试在本地运行模型,Truss 将无法帮助我们管理 CUDA 问题。
pip install transformers
pip install torch
pip install peft
pip install bitsandbytes
pip install einops
pip install scipy
接下来,在main.py脚本中,我们需要加载我们的 truss。
这是main.py的代码:
import truss
from pathlib import Path
import requests
tr = truss.load("./falcon_7b_truss")
output = tr.predict({"prompt": "Hi there how are you?"})
print(output)
这里发生了什么:
-
如果你记得,
falcon_7b_truss目录是由 truss 创建的。我们可以使用truss.load加载整个包,包括模型和依赖项。 -
一旦加载了包,我们可以简单地调用
predict方法来获取模型的输出。
运行main.py以获取模型的输出。
这些模型文件的大小约为 15 GB,因此下载模型可能需要 5 到 10 分钟。运行脚本后,你应该会看到类似的输出:
{'data': {'generated_text': "Hi there how are you?\nI'm doing well. I'm in the middle of a move, so I'm a bit tired. I'm also a bit overwhelmed. I'm not sure how to get started. I'm not sure what I'm doing. I'm not sure if I'm doing it right. I'm not sure if I'm doing it wrong. I'm not sure if I'm doing it at all.\nI'm not sure if I'm doing it right. I'm not sure if I'm doing it wrong. I"}}
第 3 步:使用 docker 容器化模型
通常,当人们容器化模型时,他们会将模型二进制文件和 Python 依赖项打包起来,使用 Flask 或 Fast API 服务器进行封装。
很多内容是样板代码,我们不想自己去做。Truss 会处理这些。我们已经提供了模型,Truss 会创建服务器,所以剩下的就是提供 Python 依赖项。
config.yaml保存了模型的配置。在这里,我们可以添加模型的依赖项。配置文件已经包含了大部分我们需要的内容,但我们还需要添加一些东西。
这是你需要添加到config.yaml中的内容:
apply_library_patches: true
bundled_packages_dir: packages
data_dir: data
description: null
environment_variables: {}
examples_filename: examples.yaml
external_package_dirs: []
input_type: Any
live_reload: false
model_class_filename: model.py
model_class_name: Model
model_framework: custom
model_metadata: {}
model_module_dir: model
model_name: Falcon-7B
model_type: custom
python_version: py39
requirements:
- torch
- peft
- sentencepiece
- accelerate
- bitsandbytes
- einops
- scipy
- git+https://github.com/huggingface/transformers.git
resources:
use_gpu: true
cpu: "3"
memory: 14Gi
secrets: {}
spec_version: '2.0'
system_packages: []
我们添加的主要内容是requirements。列出的所有依赖项都是下载和运行模型所必需的。
我们添加的另一个重要内容是resources。use_gpu: true是必需的,因为这告诉 Truss 为我们创建一个启用 GPU 支持的 Dockerfile。
这就是配置的全部内容。
接下来,我们将容器化我们的模型。如果你不知道如何使用 Docker 容器化模型,别担心,Truss 已经为你准备好了。
在main.py文件中,我们将告诉 Truss 将所有内容打包在一起。你需要的代码如下:
import truss
from pathlib import Path
import requests
tr = truss.load("./falcon_7b_truss")
command = tr.docker_build_setup(build_dir=Path("./falcon_7b_truss"))
print(command)
发生了什么:
-
首先,我们加载我们的
falcon_7b_truss -
接下来,
docker_build_setup函数处理所有复杂的内容,如创建 Dockerfile 和设置 Fast API 服务器。 -
如果你查看
falcon_7b_truss目录,你会发现生成了更多文件。我们不需要担心这些文件如何工作,因为一切都会在幕后管理。 -
在运行结束时,我们得到一个构建 Docker 镜像的命令:
docker build falcon_7b_truss -t falcon-7b-model:latest

如果你想构建 Docker 镜像,请运行构建命令。镜像大小约为 9 GB,所以可能需要一些时间来构建。如果你不想构建它但想跟随,可以使用我的镜像:htrivedi05/truss-falcon-7b:latest。
如果你自己构建镜像,你需要标记并将其推送到 Docker Hub,以便我们在云中的容器可以拉取这个镜像。以下是镜像构建完成后需要运行的命令:
docker tag falcon-7b-model <docker_user_id>/falcon-7b-model
docker push <docker_user_id>/falcon-7b-model
太棒了!我们已经准备好在云中运行我们的模型了!
(以下是可选步骤,用于在本地使用 GPU 运行镜像)
如果你有一张 Nvidia GPU 并且希望在本地使用 GPU 支持运行你的容器化模型,你需要确保 Docker 已配置为使用你的 GPU。
打开终端并运行以下命令:
distribution=$(. /etc/os-release;echo $ID$VERSION_ID) && curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add - && curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list
apt-get update
apt-get install -y nvidia-docker2
sudo systemctl restart docker
现在你的 Docker 已经配置为访问你的 GPU,下面是如何运行你的容器:
docker run --gpus all -d -p 8080:8080 falcon-7b-model
再次提醒,下载模型需要一些时间。为了确保一切正常,你可以检查容器日志,你应该会看到“THE DEVICE INFERENCE IS RUNNING ON IS: cuda”。
你可以通过 API 端点调用模型,如下所示:
import requests
data = {"prompt": "Hi there, how's it going?"}
res = requests.post("http://127.0.0.1:8080/v1/models/model:predict", json=data)
print(res.json())
第 4 步:将模型部署到生产环境
我在这里使用“生产”这个词比较宽泛。我们将把模型运行在 Kubernetes 中,那里可以轻松扩展并处理可变量的流量。
话虽如此,Kubernetes 有大量的配置,例如网络策略、存储、配置映射、负载均衡、秘密管理等。
即使 Kubernetes 是为了“扩展”和运行“生产”工作负载而构建的,很多生产级配置你需要的并不是开箱即用的。覆盖这些高级 Kubernetes 主题超出了本文的范围,也会偏离我们在这里想要实现的目标。因此,在这篇博客文章中,我们将创建一个最小化的集群,而没有所有的附加功能。
不再多说,让我们创建我们的集群吧!
先决条件:
-
拥有一个带有项目的 Google Cloud 帐户
-
在你的机器上安装gcloud CLI
-
确保你有足够的配额来运行一个启用 GPU 的机器。你可以在IAM & Admin下检查你的配额。

创建我们的 GKE 集群
我们将使用 Google 的 Kubernetes 引擎来创建和管理我们的集群。好,现在是一些重要的信息:
Google 的 Kubernetes 引擎不是免费的。Google 不允许我们免费使用强大的 GPU。话虽如此,我们正在创建一个单节点集群,配备较不强大的 GPU。这个实验的费用不应超过 1-2 美元。
这是我们将运行的 Kubernetes 集群的配置:
-
1 节点,标准 Kubernetes 集群(非自动驾驶)
-
1 张 Nvidia T4 GPU
-
n1-standard-4 机器(4 vCPU,15 GB 内存)
-
所有这些都将在一个临时实例上运行
注意:如果你在其他地区并且无法访问完全相同的资源,请随意进行修改。
创建集群的步骤:
- 前往Google Cloud Console并搜索名为Kubernetes Engine的服务

2. 点击创建按钮
- 确保你创建的是标准集群,而不是自动驾驶集群。顶部应该显示创建一个 kubernetes 集群。
3. 集群基本信息
- 在集群基本信息选项卡中,我们不需要做太多更改。只需给你的集群命名。你不需要更改区域或控制平面。

4. 点击default-pool选项卡,将节点数更改为 1

5. 在 default-pool 下,点击左侧边栏中的节点选项卡
-
将机器配置从通用型更改为GPU
-
选择Nvidia T4作为GPU 类型,并将数量设置为1
-
启用 GPU 共享(尽管我们不会使用这个功能)
-
将每 GPU 最大共享客户端设置为 8
-
对于机器类型,选择n1-standard-4(4 vCPU,15 GB 内存)
-
将启动磁盘大小更改为 50
-
向下滚动到底部,勾选启用临时虚拟机上的节点



这是我为这个集群获得的估算价格的屏幕截图:

配置完集群后,继续创建它。
Google 设置一切需要几分钟时间。集群启动并运行后,我们需要连接到它。打开终端并运行以下命令:
gcloud config set compute/zone us-central1-c
gcloud container clusters get-credentials gpu-cluster-1
如果你使用了不同的区域或集群名称,请相应更新。要检查我们是否连接成功,运行以下命令:
kubectl get nodes
你应该会在终端中看到 1 个节点。尽管我们的集群有 GPU,但缺少一些 Nvidia 驱动程序,我们需要安装它们。幸运的是,安装过程很简单。运行以下命令来安装驱动程序:
kubectl apply -f https://raw.githubusercontent.com/GoogleCloudPlatform/container-engine-accelerators/master/nvidia-driver-installer/cos/daemonset-preloaded.yaml
太棒了!我们终于准备好部署模型了。
部署模型
要将模型部署到集群中,我们需要创建一个kubernetes 部署。一个 kubernetes 部署允许我们管理容器化模型的实例。我不会深入探讨 kubernetes 或如何编写 yaml 文件,因为这超出了范围。
你需要创建一个名为truss-falcon-deployment.yaml的文件。打开该文件并粘贴以下内容:
apiVersion: apps/v1
kind: Deployment
metadata:
name: truss-falcon-7b
namespace: default
spec:
replicas: 1
selector:
matchLabels:
component: truss-falcon-7b-layer
template:
metadata:
labels:
component: truss-falcon-7b-layer
spec:
containers:
- name: truss-falcon-7b-container
image: <your_docker_id>/falcon-7b-model:latest
ports:
- containerPort: 8080
resources:
limits:
nvidia.com/gpu: 1
---
apiVersion: v1
kind: Service
metadata:
name: truss-falcon-7b-service
namespace: default
spec:
type: ClusterIP
selector:
component: truss-falcon-7b-layer
ports:
- port: 8080
protocol: TCP
targetPort: 8080
发生了什么:
-
我们告诉 kubernetes 我们想用
falcon-7b-model镜像创建 pods。确保将<your_docker_id>替换为你的实际 id。如果你没有创建自己的 docker 镜像而想使用我的镜像,请将其替换为:htrivedi05/truss-falcon-7b:latest -
我们通过设置资源限制
nvidia.com/gpu: 1来启用容器的 GPU 访问。这告诉 kubernetes 只请求一个 GPU 给我们的容器 -
要与我们的模型交互,我们需要创建一个将在端口 8080 上运行的 kubernetes 服务。
在终端中运行以下命令以创建部署:
kubectl create -f truss-falcon-deployment.yaml
如果你运行命令:
kubectl get deployments
你应该看到如下内容:
NAME READY UP-TO-DATE AVAILABLE AGE
truss-falcon-7b 0/1 1 0 8s
部署变为准备状态需要几分钟时间。请记住,每次容器重启时,模型都需要从 hugging face 下载。你可以通过运行以下命令来检查容器的进度:
kubectl get pods
kubectl logs truss-falcon-7b-8fbb476f4-bggts
相应地更改 pod 名称。
在日志中,你需要查看以下几个方面:
-
查找打印语句THE DEVICE INFERENCE IS RUNNING ON IS: cuda。这确认了我们的容器已正确连接到 GPU。
-
接下来,你应该看到一些关于模型文件下载的打印语句。
Downloading (…)model.bin.index.json: 100%|██████████| 16.9k/16.9k [00:00<00:00, 1.92MB/s]
Downloading (…)l-00001-of-00002.bin: 100%|██████████| 9.95G/9.95G [02:37<00:00, 63.1MB/s]
Downloading (…)l-00002-of-00002.bin: 100%|██████████| 4.48G/4.48G [01:04<00:00, 69.2MB/s]
Downloading shards: 100%|██████████| 2/2 [03:42<00:00, 111.31s/it][01:04<00:00, 71.3MB/s]
- 一旦模型下载完成且 Truss 创建了微服务,你应该在日志末尾看到以下输出:
{"asctime": "2023-06-29 21:40:40,646", "levelname": "INFO", "message": "Completed model.load() execution in 330588 ms"}
从此消息中,我们可以确认模型已加载并准备好进行推断。
模型推断
我们不能直接调用模型,而是必须调用模型的服务。
运行以下命令以获取服务名称:
kubectl get svc
输出:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.80.0.1 <none> 443/TCP 46m
truss-falcon-7b-service ClusterIP 10.80.1.96 <none> 8080/TCP 6m19s
我们要调用的是truss-falcon-7b-service。为了使服务可访问,我们需要使用以下命令进行端口转发:
kubectl port-forward svc/truss-falcon-7b-service 8080
输出:
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080
太棒了,我们的模型现在作为 REST API 端点可用,地址是127.0.0.1:8080。打开任何 Python 脚本,比如main.py,并运行以下代码:
import requests
data = {"prompt": "Whats the most interesting thing about a falcon?"}
res = requests.post("http://127.0.0.1:8080/v1/models/model:predict", json=data)
print(res.json())
输出:
{'data': {'generated_text': 'Whats the most interesting thing about a falcon?\nFalcons are known for their incredible speed and agility in the air, as well as their impressive hunting skills. They are also known for their distinctive feathering, which can vary greatly depending on the species.'}}
哇!我们成功地将我们的 Falcon 7B 模型容器化,并将其作为微服务在生产环境中部署!
随意尝试不同的提示,看看模型返回什么。
关闭集群
当你玩够了 Falcon 7B 后,你可以通过运行以下命令来删除你的部署:
kubectl delete -f truss-falcon-deployment.yaml
接下来,前往 Google Cloud 中的 kubernetes 引擎并删除 kubernetes 集群。
注意:除非另有说明,否则所有图像均为作者提供。
结论
运行和管理像 ChatGPT 这样的生产级模型并不容易。然而,随着时间的推移,工具将变得更好,开发者将能更轻松地将自己的模型部署到云端。
在这篇博客文章中,我们讨论了在基本层面上将 LLM 部署到生产环境所需的所有事项。我们使用 Truss 打包模型,通过 Docker 进行容器化,并使用 kubernetes 将其部署到云端。我知道这要处理的内容很多,而且这并不是世界上最容易的事情,但我们还是完成了。
希望你从这篇博客文章中学到了一些有趣的东西。感谢阅读!
平安。
使用 HuggingFace TGI 部署大型语言模型
原文:
towardsdatascience.com/deploying-large-language-models-with-huggingface-tgi-981747c669e3
使用 Amazon SageMaker 高效托管和扩展您的 LLMs 的另一种方法
·发表于 Towards Data Science ·阅读时间 5 分钟·2023 年 7 月 14 日
--

图片来自 Unsplash
大型语言模型(LLMs)的受欢迎程度持续上升,几乎每周都有新的模型发布。随着这些模型数量的增加,我们托管它们的选择也越来越多。在我之前的文章中,我们探讨了如何在 Amazon SageMaker 中利用 DJL Serving 来高效托管 LLMs。在这篇文章中,我们将探索另一种优化的模型服务器和解决方案——HuggingFace 文本生成推理(TGI)。
注意:如果你是 AWS 新手,请确保你在以下 链接 创建一个账户,以便跟进本文。文章还假设你对 SageMaker 部署有一定的了解,我建议你阅读这篇 文章 以更深入地了解部署/推理。
免责声明:我是在 AWS 的机器学习架构师,我的观点仅代表个人意见。
为什么选择 HuggingFace 文本生成推理?它如何与 Amazon SageMaker 配合使用?
TGI 是由 HuggingFace 创建的 Rust、Python、gRPC 模型服务器,旨在托管特定的大型语言模型。HuggingFace 一直是 NLP 的核心枢纽,它在 LLMs 上包含了大量优化,下面列出了一些,详细的优化列表请参见 文档。
-
在多个 GPU 上高效托管的张量并行
-
使用 SSE 进行令牌流媒体
-
使用 bitsandbytes 进行量化
-
Logits warper(不同的参数,例如温度、top-k、top-n 等)
我注意到这个解决方案的一个很大优点是使用的简便性。目前,TGI 支持以下优化的模型架构,你可以直接使用 TGI 容器进行部署。
更为方便的是,它可以直接与 Amazon SageMaker 集成,正如我们将在本文中探讨的那样。SageMaker 现在提供了托管的 TGI 容器,我们将 检索 并使用这些容器来在本示例中部署 Llama 模型。
TGI 与 DJL Serving 与 JumpStart 的比较
到目前为止,在这个 LLM 托管系列中,我们已经探讨了两种 Amazon SageMaker 的替代选项:
-
DJL Serving
-
SageMaker JumpStart
我们何时使用什么,TGI 在其中扮演什么角色? 如果模型已经在 SageMaker JumpStart 的服务中提供,那么 JumpStart 非常适合。你实际上不需要处理任何容器或模型服务器级别的工作,这些都为你抽象化了。如果你有一个不被 JumpStart 支持的自定义用例,DJL Serving 是一个很好的选择。你可以直接从 HuggingFace Hub 拉取,或者加载自己在 S3 中的工件并指向它。另一个优点是,如果你掌握了特定的模型分区框架,例如 Accelerate 或 FasterTransformers,DJL Serving 与这些框架集成,可以让你为 LLM 托管添加一些高级性能优化技术。
TGI 的作用是什么?正如我们所提到的,TGI 原生支持特定的模型架构。如果你尝试部署这些支持的模型,TGI 是一个很好的选择,因为它专门为这些架构进行了优化。此外,如果你更深入地了解模型服务器,你可以调整一些暴露的环境变量。可以将 TGI 视为易用性和性能优化之间的交集。根据你的用例及权衡每种解决方案的优缺点,选择合适的选项对于你的部署至关重要。
在 Amazon SageMaker 上使用 TGI 部署 Llama
为了与 Amazon SageMaker 配合工作,我们将利用SageMaker Python SDK来获取我们的 TGI 容器并简化部署。在这个示例中,我们将遵循 Phillip Schmid 的TGI 博客并将其调整为 Llama。确保首先安装最新版本的 SageMaker Python SDK 并设置必要的客户端以便与服务配合使用。
!pip install "sagemaker==2.163.0" --upgrade --quiet
import sagemaker
import boto3
sess = sagemaker.Session()
sagemaker_session_bucket=None
if sagemaker_session_bucket is None and sess is not None:
# set to default bucket if a bucket name is not given
sagemaker_session_bucket = sess.default_bucket()
try:
role = sagemaker.get_execution_role()
except ValueError:
iam = boto3.client('iam')
role = iam.get_role(RoleName='sagemaker_execution_role')['Role']['Arn']
sess = sagemaker.Session(default_bucket=sagemaker_session_bucket)
print(f"sagemaker role arn: {role}")
print(f"sagemaker session region: {sess.boto_region_name}")
接下来我们检索部署 TGI 所需的 SageMaker 容器。AWS 管理一组深度学习容器(你也可以自带容器),这些容器与不同的模型服务器如 TGI、DJL Serving、TorchServe 等集成。在这种情况下,我们将 SDK 指向我们需要的 TGI 版本。
from sagemaker.huggingface import get_huggingface_llm_image_uri
# retrieve the llm image uri
llm_image = get_huggingface_llm_image_uri(
"huggingface",
version="0.8.2"
)
# print ecr image uri
print(f"llm image uri: {llm_image}")
接下来是与 TGI 容器相关的具体魔法。TGI 的简单之处在于,我们只需提供特定 LLM 的模型中心 ID。请注意,它必须来自 TGI 支持的模型架构。在这种情况下,我们使用的是Llama-7B 模型的一种形式。
import json
from sagemaker.huggingface import HuggingFaceModel
# Define Model and Endpoint configuration parameter
config = {
'HF_MODEL_ID': "decapoda-research/llama-7b-hf", # model_id from hf.co/models
'SM_NUM_GPUS': json.dumps(number_of_gpu), # Number of GPU used per replica
'MAX_INPUT_LENGTH': json.dumps(1024), # Max length of input text
'MAX_TOTAL_TOKENS': json.dumps(2048), # Max length of the generation (including input text)
}
在上面的配置对象中,你还可以指定 TGI 容器支持的任何额外优化,例如量化格式(例如:bitsandbytes)。然后我们将这个配置传递给 SageMaker 理解的 HuggingFace 模型对象。
# create HuggingFaceModel with the image uri
llm_model = HuggingFaceModel(
role=role,
image_uri=llm_image,
env=config
)
然后我们可以直接将这个模型对象部署到 SageMaker 实时端点。在这里你可以指定你的硬件,对于这些较大的模型,建议使用如 g5 实例的 GPU 系列。由于这些模型的大小,我们还启用了更长的容器健康检查超时时间。
instance_type = "ml.g5.12xlarge"
number_of_gpu = 4
health_check_timeout = 300
llm = llm_model.deploy(
initial_instance_count=1,
instance_type=instance_type,
container_startup_health_check_timeout=health_check_timeout,
)
创建端点应该需要几分钟时间,但随后你应该能够使用以下代码进行样本推断。
llm.predict({
"inputs": "My name is Julien and I like to",
})
附加资源与结论
[## SageMaker-Deployment/LLM-Hosting/TGI/tgi-llama.ipynb 在 master 分支 · RamVegiraju/SageMaker-Deployment
对 SageMaker 推理选项和其他功能的示例进行了汇编。…
你可以在上述链接找到整个示例的代码,也可以从 HuggingFace Hub 的 SageMaker 部署选项卡直接生成此代码以支持你的模型。文本生成推理提供了一个高度优化的模型服务器,这也大大简化了部署过程。请继续关注更多关于 LLM 领域的内容,感谢阅读,欢迎随时留下反馈。
如果你喜欢这篇文章,欢迎通过 LinkedIn 与我联系,并订阅我的 Medium Newsletter。如果你是 Medium 新手,可以通过我的 会员推荐注册。
在 Amazon SageMaker 上使用 DJL Serving 部署 LLMs
原文:
towardsdatascience.com/deploying-llms-on-amazon-sagemaker-with-djl-serving-8220e3cfad0c
在 Amazon SageMaker 实时推理中部署 BART
·发表于 Towards Data Science ·8 分钟阅读·2023 年 6 月 7 日
--

图片来源:Unsplash
在 2023 年,大语言模型(LLMs)和生成式 AI 继续在机器学习和一般技术领域中占据主导地位。随着 LLM 的扩展,新模型不断涌现,并以惊人的速度不断改进。
尽管这些模型的准确性和性能令人难以置信,但在托管这些模型时仍面临着一系列挑战。如果没有模型托管,很难识别这些大语言模型(LLMs)在实际应用中所提供的价值。LLM 托管和性能调优的具体挑战是什么?
-
我们如何加载这些体积达到数百 GB 的大型模型?
-
我们如何正确应用模型分区技术,以高效利用硬件而不影响模型的准确性?
-
我们如何将这些模型适配到单个 GPU 或多个 GPU 上?
这些都是具有挑战性的问题,通过一个被称为 DJL Serving 的模型服务器进行解决和抽象。DJL Serving 是一个高性能的通用解决方案,可以直接与各种模型分区框架集成,例如:HuggingFace Accelerate、DeepSpeed 和 FasterTransformers。使用 DJL Serving,你可以配置你的服务堆栈,利用这些分区框架在多个 GPU 上优化大模型的推理。
在今天的文章中,我们特别探讨了 BART 中的一个较小的语言模型用于特征提取。我们将展示如何使用 DJL Serving 配置你的服务堆栈并托管你选择的HuggingFace 模型。这个示例可以作为一个模板,构建和利用上述的模型分区框架。然后我们将把 DJL 特定代码与 SageMaker 集成,创建一个实时端点,供你进行推理使用。
注意:对于那些新接触 AWS 的朋友,如果你想跟随本文操作,请确保在以下链接上创建一个账户。本文假设你对 SageMaker 部署有中级理解,建议你阅读这篇文章以更深入理解部署/推理。
什么是模型服务器?Amazon SageMaker 支持哪些模型服务器?
模型服务器的基本概念是“作为服务的推理”。我们需要一种简便的方法通过 API 暴露我们的模型,而这些模型服务器则处理背后的繁重工作。这些模型服务器加载和卸载我们的模型工件,并提供托管 ML 模型所需的运行时环境。这些模型服务器也可以根据它们向用户暴露的内容进行调优。例如,TensorFlow Serving 允许你选择gRPC 还是 REST进行 API 调用。
Amazon SageMaker 与各种不同的模型服务器集成,这些服务器也通过 AWS 提供的不同深度学习容器进行暴露。这些模型服务器包括以下几种:
在这个具体示例中,我们将使用 DJL Serving,因为它针对大语言模型托管进行了不同的模型分区框架。这并不意味着服务器仅限于 LLM,你也可以将其用于其他模型,只要你正确配置环境以安装和加载其他依赖项。
从很高的层次来看,具体使用的模型服务器会影响你提供给服务器的工件的构建和形状,这是唯一的区别,还有它们支持的模型框架和环境。
DJL Serving 与 JumpStart
在我之前的文章中,我们探讨了如何通过 SageMaker JumpStart 部署 Cohere 的语言模型。为什么这次不使用 SageMaker JumpStart?目前并非所有 LLM 都得到 SageMaker JumpStart 的支持。如果有特定的 LLM JumpStart 不支持,那么使用 DJL Serving 就是明智的选择。
DJL Serving 的另一个主要用例是当涉及到自定义和性能优化时。使用 JumpStart,你受限于模型提供以及已为你预先准备的容器的限制。使用 DJL,虽然在容器级别需要更多的代码工作,但你可以使用不同的分区框架应用你选择的性能优化技术。
DJL Serving 设置
对于这个代码示例,我们将使用一个 ml.c5.9xlarge SageMaker Classic Notebook 实例 和一个 conda_amazonei_pytorch_latest_p37 内核进行开发。
在我们进入 DJL Serving 设置之前,我们可以快速探索 BART 模型。这个模型可以在 HuggingFace Model Hub 中找到,并可以用于各种任务,如特征提取和摘要。以下代码片段展示了如何在本地使用 BART Tokenizer 和 Model 进行示例推理。
from transformers import AutoTokenizer, AutoModel
tokenizer = AutoTokenizer.from_pretrained("facebook/bart-large")
model = AutoModel.from_pretrained("facebook/bart-large")
inputs = tokenizer("Hello, my dog is cute", return_tensors="pt")
outputs = model(**inputs)
last_hidden_states = outputs.last_hidden_state
last_hidden_states
现在我们可以通过几个特定的文件将此模型映射到 DJL Serving。首先,我们定义一个 serving.properties 文件,该文件本质上定义了模型部署的配置。在这种情况下,我们指定了一些参数。
-
引擎:我们为 DJL 引擎使用 Python,其他选项还包括 DeepSpeed、FasterTransformers 和 Accelerate。
-
模型 _ID:对于 HuggingFace Hub,每个模型都有一个可以用作标识符的 model_id,我们可以将其传递到我们的模型脚本中用于模型加载。
-
任务:对于 HuggingFace 特定的模型,你可以包含一个任务,因为这些模型可以支持各种语言任务,在这种情况下,我们指定了特征提取。
engine=Python
option.model_id=facebook/bart-large
option.task=feature-extraction
你可以为 DJL 指定的其他配置包括:tensor_parallel degree,每个模型的最小和最大工作线程。有关可以配置的属性的详细列表,请参阅以下文档。
接下来我们提供的是实际的模型工件和一个 requirements.txt 文件,用于你在推理脚本中使用的任何额外库。
numpy
在这种情况下,我们没有模型工件,因为我们将在推断脚本中直接从 HuggingFace Hub 加载模型。
在我们的推断脚本(model.py)中,我们可以创建一个类来捕获模型加载和推断。
class BartModel(object):
"""
Deploying Bart with DJL Serving
"""
def __init__(self):
self.initialized = False
我们的初始化方法将解析我们的 serving.properties 文件,并从 HuggingFace 模型库加载 BART 模型和分词器。属性对象本质上包含了你在 serving.properties 文件中定义的所有内容。
def initialize(self, properties: dict):
"""
Initialize model.
"""
logging.info(properties)
tokenizer = AutoTokenizer.from_pretrained("facebook/bart-large")
model = AutoModel.from_pretrained("facebook/bart-large")
self.model_name = properties.get("model_id")
self.task = properties.get("task")
self.model = AutoModel.from_pretrained(self.model_name)
self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
self.initialized = True
然后我们定义一个推断方法,该方法接受一个字符串输入,并对文本进行分词,以进行 BART 模型的推断,我们可以从上面的本地推断示例中复制。
def inference(self, inputs):
"""
Custom service entry point function.
:param inputs: the Input object holds the text for the BART model to infer upon
:return: the Output object to be send back
"""
#sample input: "This is the sample text that I am passing in"
try:
data = inputs.get_as_string()
inputs = self.tokenizer(data, return_tensors="pt")
preds = self.model(**inputs)
res = preds.last_hidden_state.detach().cpu().numpy().tolist() #convert to JSON Serializable object
outputs = Output()
outputs.add_as_json(res)
except Exception as e:
logging.exception("inference failed")
# error handling
outputs = Output().error(str(e))
我们然后实例化这个类,并在“handle”方法中捕获所有这些。默认情况下,对于 DJL Serving,这是处理程序在推断脚本中解析的方法。
_service = BartModel()
def handle(inputs: Input):
"""
Default handler function
"""
if not _service.initialized:
# stateful model
_service.initialize(inputs.get_properties())
if inputs.is_empty():
return None
return _service.inference(inputs)
现在我们在 DJL Serving 端已经拥有所有必要的工件,并可以配置这些文件以适应 SageMaker 构造,以创建实时终端节点。
SageMaker 终端节点创建与推断
创建 SageMaker 终端节点的过程与其他模型服务器(如 MMS)非常相似。我们需要两个工件来创建一个 SageMaker 模型实体。
-
model.tar.gz: 这将包含我们特定于 DJL 的文件,我们将这些文件组织成模型服务器所期望的格式。
-
容器镜像: SageMaker 推断始终期望一个容器,在这种情况下我们使用 AWS 提供和维护的 DJL Deepseed 镜像。
我们可以创建模型 tarball,将其上传到 S3,然后检索镜像以准备推断所需的工件。
import sagemaker, boto3
from sagemaker import image_uris
# retreive DeepSpeed image
img_uri = image_uris.retrieve(framework="djl-deepspeed",
region=region, version="0.21.0")
# create model tarball
bashCommand = "tar -cvpzf model.tar.gz model.py requirements.txt serving.properties"
process = subprocess.Popen(bashCommand.split(), stdout=subprocess.PIPE)
output, error = process.communicate()
# Upload tar.gz to bucket
model_artifacts = f"s3://{bucket}/model.tar.gz"
response = s3.meta.client.upload_file('model.tar.gz', bucket, 'model.tar.gz')
我们可以利用Boto3 SDK来进行模型、终端节点配置和终端节点的创建。唯一不同于通常的三个 API 调用的是,在终端节点配置 API 调用中,我们将模型下载超时和容器健康检查超时参数设置为更高的值,因为我们在此情况下处理的是一个更大的模型。我们还利用了 g5 系列实例以获得额外的 GPU 计算能力。对于大多数 LLM 来说,GPU 是必需的,以便能够在这种规模下托管模型。
client = boto3.client(service_name="sagemaker")
model_name = "djl-bart" + strftime("%Y-%m-%d-%H-%M-%S", gmtime())
print("Model name: " + model_name)
create_model_response = client.create_model(
ModelName=model_name,
ExecutionRoleArn=role,
PrimaryContainer={"Image": img_uri, "ModelDataUrl": model_artifacts},
)
print("Model Arn: " + create_model_response["ModelArn"])
endpoint_config_name = "djl-bart" + strftime("%Y-%m-%d-%H-%M-%S", gmtime())
production_variants = [
{
"VariantName": "AllTraffic",
"ModelName": model_name,
"InitialInstanceCount": 1,
"InstanceType": 'ml.g5.12xlarge',
"ModelDataDownloadTimeoutInSeconds": 1800,
"ContainerStartupHealthCheckTimeoutInSeconds": 3600,
}
]
endpoint_config = {
"EndpointConfigName": endpoint_config_name,
"ProductionVariants": production_variants,
}
endpoint_config_response = client.create_endpoint_config(**endpoint_config)
print("Endpoint Configuration Arn: " + endpoint_config_response["EndpointConfigArn"])
endpoint_name = "djl-bart" + strftime("%Y-%m-%d-%H-%M-%S", gmtime())
create_endpoint_response = client.create_endpoint(
EndpointName=endpoint_name,
EndpointConfigName=endpoint_config_name,
)
print("Endpoint Arn: " + create_endpoint_response["EndpointArn"])
一旦创建了端点,我们可以利用 invoke_endpoint API 调用进行样本推理,你应该会看到返回一个 numpy 数组。
runtime = boto3.client(service_name="sagemaker-runtime")
response = runtime.invoke_endpoint(
EndpointName=endpoint_name,
ContentType="text/plain",
Body="I think my dog is really cute!")
result = json.loads(response['Body'].read().decode())
附加资源与结论
## SageMaker-Deployment/LLM-Hosting/DJL-Serving/BART at master · RamVegiraju/SageMaker-Deployment
目前无法执行该操作。你在另一个标签页或窗口中登录了。你在另一个标签页或窗口中注销了……
你可以在上面的链接找到整个示例的代码。LLM Hosting 仍然是一个不断发展的领域,面临许多挑战,DJL Serving 可以帮助简化这些挑战。结合 SageMaker 提供的硬件和优化,这可以帮助提升你在 LLMs 上的推理性能。
一如既往,欢迎随时对文章提出反馈或问题。感谢阅读,敬请关注更多关于 LLM 领域的内容。
如果你喜欢这篇文章,可以通过 LinkedIn 与我联系,并订阅我的 Medium Newsletter。如果你是 Medium 新手,可以使用我的 Membership Referral进行注册。
使用 SageMaker Pipelines 部署多个模型
原文:
towardsdatascience.com/deploying-multiple-models-with-sagemaker-pipelines-fb7363094c50
应用 MLOps 最佳实践于高级服务选项
·发表于 Towards Data Science ·8 分钟阅读·2023 年 3 月 23 日
--

MLOps 是将你的机器学习工作流投入生产的关键实践。通过 MLOps,你可以建立适用于 ML 生命周期的工作流。这使得集中维护资源、更新/跟踪模型变得更容易,并且随着你的 ML 实验规模的扩大,整个过程变得更加简化。
Amazon SageMaker 生态系统中的一个关键 MLOps 工具是 SageMaker Pipelines。通过 SageMaker Pipelines,你可以定义由不同的 ML 步骤 组成的工作流。你还可以通过定义 参数 来结构化这些工作流,并将这些参数作为变量注入到你的管道中。有关 SageMaker Pipelines 的更一般介绍,请参考 相关文章。
定义管道本身并不复杂,但有一些高级用例需要额外的配置。具体来说,假设你正在训练多个模型,这些模型在你的机器学习用例中用于推理。在 SageMaker 中,有一个被称为 多模型端点(MME)的托管选项,你可以在单个端点上托管多个模型并调用目标模型。然而,目前在 SageMaker Pipelines 中没有原生支持定义或部署 MME。在这篇博客文章中,我们将探讨如何利用 Pipelines Lambda 步骤 以自定义方式部署多模型端点,同时遵循 MLOps 最佳实践。
注意:如果你是 AWS 的新手,请确保在以下链接上注册一个账户,以便跟随操作。本文还假设你对 SageMaker 部署有中级了解,我建议参考这篇文章以深入理解部署/推理。特别是,对于 SageMaker 多模型终端,我建议参考以下博客。
设置
在这个例子中,我们将在SageMaker Studio中工作,这里我们可以访问 SageMaker 管道和其他 SageMaker 组件的可视化界面。为了开发,我们将利用一个带有数据科学内核的 Studio Notebook 实例,在一个 ml.t3.medium 实例上。要开始,我们首先需要导入在 SageMaker 管道中将要使用的不同步骤所需的库。
import os
import boto3
import re
import time
import json
from sagemaker import get_execution_role, session
import pandas as pd
from time import gmtime, strftime
import sagemaker
from sagemaker.model import Model
from sagemaker.image_uris import retrieve
from sagemaker.workflow.pipeline_context import PipelineSession
from sagemaker.workflow.model_step import ModelStep
from sagemaker.inputs import TrainingInput
from sagemaker.workflow.steps import TrainingStep
from sagemaker.workflow.parameters import ParameterString
from sagemaker.estimator import Estimator
# Custom Lambda Step
from sagemaker.workflow.lambda_step import (
LambdaStep,
LambdaOutput,
LambdaOutputTypeEnum,
)
from sagemaker.lambda_helper import Lambda
from sagemaker.workflow.pipeline import Pipeline
接下来,我们创建一个管道会话,这个管道会话确保在管道实际执行之前,没有任何训练作业会在笔记本中实际执行。
pipeline_session = PipelineSession()
在这个例子中,我们将利用Abalone 数据集(CC BY 4.0)并在其上运行SageMaker XGBoost 算法来进行回归模型。你可以从公开可用的 Amazon 数据集中下载该数据集。
!aws s3 cp s3://sagemaker-sample-files/datasets/tabular/uci_abalone/train_csv/abalone_dataset1_train.csv .
!aws s3 cp abalone_dataset1_train.csv s3://{default_bucket}/xgboost-regression/train.csv
training_path = 's3://{}/xgboost-regression/train.csv'.format(default_bucket)
然后我们可以参数化我们的管道,通过定义训练数据集和实例类型的默认值。
training_input_param = ParameterString(
name = "training_input",
default_value=training_path,
)
training_instance_param = ParameterString(
name = "training_instance",
default_value = "ml.c5.xlarge")
然后我们还会检索AWS 提供的容器,这是我们将用于训练和推理的 XGBoost 容器。
model_path = f's3://{default_bucket}/{s3_prefix}/xgb_model'
image_uri = sagemaker.image_uris.retrieve(
framework="xgboost",
region=region,
version="1.0-1",
py_version="py3",
instance_type=training_instance_param,
)
image_uri
训练设置
在我们管道的训练部分,我们将配置 SageMaker XGBoost 算法以适应我们的回归 Abalone 数据集。
xgb_train_one = Estimator(
image_uri=image_uri,
instance_type=training_instance_param,
instance_count=1,
output_path=model_path,
sagemaker_session=pipeline_session,
role=role
)
xgb_train_one.set_hyperparameters(
objective="reg:linear",
num_round=40,
max_depth=4,
eta=0.1,
gamma=3,
min_child_weight=5,
subsample=0.6,
silent=0,
)
对于我们的第二个估算器,我们将更改超参数来调整模型训练,这样我们就在我们的多模型终端后面有两个独立的模型。
xgb_train_two = Estimator(
image_uri=image_uri,
instance_type=training_instance_param,
instance_count=1,
output_path=model_path,
sagemaker_session=pipeline_session,
role=role
)
#adjusting hyperparams
xgb_train_two.set_hyperparameters(
objective="reg:linear",
num_round=50,
max_depth=5,
eta=0.2,
gamma=4,
min_child_weight=6,
subsample=0.7,
silent=0,
)
然后我们配置两个估算器的训练输入,以指向我们为 S3 训练数据集定义的参数。
train_args_one = xgb_train_one.fit(
inputs={
"train": TrainingInput(
s3_data=training_input_param,
content_type="text/csv",
)
}
)
train_args_two = xgb_train_two.fit(
inputs={
"train": TrainingInput(
s3_data=training_input_param,
content_type="text/csv",
)
}
)
然后我们定义两个独立的训练步骤,这些步骤将通过我们的管道并行执行。
step_train_one = TrainingStep(
name="TrainOne",
step_args=train_args_one,
)
step_train_two = TrainingStep(
name = "TrainTwo",
step_args= train_args_two
)
Lambda 步骤
Lambda 步骤 本质上允许你在管道中插入 Lambda 函数。每个 SageMaker 训练作业都会生成一个包含训练模型工件的 model.tar.gz 文件。这里我们将利用 Lambda 步骤来检索训练好的模型工件,并将其部署到 SageMaker 多模型端点。
在此之前,我们需要给予 Lambda 函数适当的权限来使用 SageMaker。我们可以使用以下现有 脚本 来为 Lambda 函数创建一个 IAM 角色。
import boto3
import json
iam = boto3.client("iam")
def create_lambda_role(role_name):
try:
response = iam.create_role(
RoleName=role_name,
AssumeRolePolicyDocument=json.dumps(
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole",
}
],
}
),
Description="Role for Lambda to call SageMaker functions",
)
role_arn = response["Role"]["Arn"]
response = iam.attach_role_policy(
RoleName=role_name,
PolicyArn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
)
response = iam.attach_role_policy(
PolicyArn="arn:aws:iam::aws:policy/AmazonSageMakerFullAccess", RoleName=role_name
)
return role_arn
except iam.exceptions.EntityAlreadyExistsException:
print(f"Using ARN from existing role: {role_name}")
response = iam.get_role(RoleName=role_name)
return response["Role"]["Arn"]
from iam_helper import create_lambda_role
lambda_role = create_lambda_role("lambda-deployment-role")
在定义了 Lambda 角色后,我们可以创建一个 Lambda 函数,为我们完成一些任务:
-
将每个训练作业的 model.tar.gz 文件放入包含两个 tarball 的中央 S3 位置。对于MME 他们期望所有模型 tarball 都在一个单一的 S3 路径中。
-
使用 boto3 客户端与 SageMaker 一起创建 SageMaker 模型、端点配置和端点。
我们可以利用以下辅助函数来完成第一个任务,将训练作业工件复制到包含两个模型 tarball 的中央 S3 位置。
sm_client = boto3.client("sagemaker")
s3 = boto3.resource('s3')
def extract_bucket_key(model_data):
"""
Extracts the bucket and key from the model data tarballs that we are passing in
"""
bucket = model_data.split('/', 3)[2]
key = model_data.split('/', 3)[-1]
return [bucket, key]
def create_mme_dir(model_data_dir):
"""
Takes in a list of lists with the different trained models,
creates a central S3 bucket/key location with all model artifacts for MME.
"""
bucket_name = model_data_dir[0][0]
for i, model_data in enumerate(model_data_dir):
copy_source = {
'Bucket': bucket_name,
'Key': model_data[1]
}
bucket = s3.Bucket(bucket_name)
destination_key = 'xgboost-mme-pipelines/model-{}.tar.gz'.format(i)
bucket.copy(copy_source, destination_key)
mme_s3_path = 's3://{}/xgboost-mme-pipelines/'.format(bucket_name)
return mme_s3_path
我们的 Lambda 函数的下一步是创建实时端点所需的 SageMaker 实体:
-
SageMaker 模型:包含模型数据和容器镜像,同时定义了多模型与单模型端点。
-
SageMaker 端点配置:定义了端点背后的硬件、实例类型和数量。
-
SageMaker 端点:可以调用进行推理的 REST 端点,对于 MME,你还需要指定要进行推理的模型。
model_name = 'mme-source' + strftime("%Y-%m-%d-%H-%M-%S", gmtime())
create_model_response = sm_client.create_model(
ModelName=model_name,
Containers=[
{
"Image": image_uri,
"Mode": "MultiModel",
"ModelDataUrl": model_url
}
],
#to-do parameterize this
ExecutionRoleArn='arn:aws:iam::474422712127:role/sagemaker-role-BYOC',
)
print("Model Arn: " + create_model_response["ModelArn"])
#Step 2: EPC Creation
xgboost_epc_name = "mme-source" + strftime("%Y-%m-%d-%H-%M-%S", gmtime())
endpoint_config_response = sm_client.create_endpoint_config(
EndpointConfigName=xgboost_epc_name,
ProductionVariants=[
{
"VariantName": "xgbvariant",
"ModelName": model_name,
"InstanceType": "ml.c5.large",
"InitialInstanceCount": 1
},
],
)
print("Endpoint Configuration Arn: " + endpoint_config_response["EndpointConfigArn"])
#Step 3: EP Creation
endpoint_name = "mme-source" + strftime("%Y-%m-%d-%H-%M-%S", gmtime())
create_endpoint_response = sm_client.create_endpoint(
EndpointName=endpoint_name,
EndpointConfigName=xgboost_epc_name,
)
print("Endpoint Arn: " + create_endpoint_response["EndpointArn"])
一旦我们能够开始创建端点,我们会通过 Lambda 函数返回成功消息。
return {
"statusCode": 200,
"body": json.dumps("Created Endpoint!"),
"endpoint_name": endpoint_name
}
然后我们以管道所需的 Lambda 步骤格式定义此 Lambda 函数。
# Lambda helper class can be used to create the Lambda function
func = Lambda(
function_name=function_name,
execution_role_arn=lambda_role,
script="code/lambda_helper.py",
handler="lambda_helper.lambda_handler",
)
我们还定义了 Lambda 函数返回的输出参数。
output_param_1 = LambdaOutput(output_name="statusCode", output_type=LambdaOutputTypeEnum.String)
output_param_2 = LambdaOutput(output_name="body", output_type=LambdaOutputTypeEnum.String)
output_param_3 = LambdaOutput(output_name="endpoint_name", output_type=LambdaOutputTypeEnum.String)
然后我们定义输入,使用在之前的笔记本中定义的两个不同训练模型工件。
step_deploy_lambda = LambdaStep(
name="LambdaStep",
lambda_func=func,
inputs={
"model_artifacts_one": step_train_one.properties.ModelArtifacts.S3ModelArtifacts,
"model_artifacts_two": step_train_two.properties.ModelArtifacts.S3ModelArtifacts
},
outputs=[output_param_1, output_param_2, output_param_3],
)
管道执行与示例推理
现在我们已配置好不同的步骤,可以将它们组合成一个单一的管道。我们指向我们定义的三个不同步骤及其参数。请注意,根据你的用例,你还可以定义比我们这里更多的参数。
pipeline = Pipeline(
name="mme-pipeline",
steps=[step_train_one, step_train_two, step_deploy_lambda],
parameters= [training_input_param, training_instance_param]
)
我们现在可以使用以下命令执行管道。
pipeline.upsert(role_arn=role)
execution = pipeline.start()
execution.wait()
执行后,我们注意到在 Pipelines 标签的 Studio UI 中,为你的 Pipeline 创建了一个有向无环图(DAG)以显示你的工作流。

MME DAG(作者截图)
几分钟后,你还应该在 SageMaker 控制台中看到已创建的端点。

端点创建完成(作者截图)
我们可以通过一个示例推断来测试这个端点,以确保它正常工作。
import boto3
smr = boto3.client('sagemaker-runtime') #client for inference
#specify the tarball you are invoking in the TargetModel param
resp = smr.invoke_endpoint(EndpointName=endpoint_name, Body=b'.345,0.224414,.131102,0.042329,.279923,-0.110329,-0.099358,0.0',
ContentType='text/csv', TargetModel = 'model-0.tar.gz')
print(resp['Body'].read())
附加资源与总结
[## GitHub - RamVegiraju/sagemaker-pipelines-examples: SageMaker Pipelines 示例库
此时你无法执行该操作。你在另一个标签页或窗口中登录了。你在另一个标签页中退出了…
整个示例的代码可以在上面的链接中找到(敬请关注更多 Pipelines 示例)。这个示例将高级托管选项与 MLOPs 最佳实践结合起来。在你扩展 ML 实验时,利用 MLOPs 工具至关重要,因为它有助于简化和参数化你的工作,使团队更容易协作和跟踪。我希望这篇文章对在 MME 中使用 Pipelines 进行特定托管用例提供了一个良好的概述。像往常一样,任何反馈都很受欢迎,感谢阅读!
如果你喜欢这篇文章,可以随时通过 LinkedIn 与我联系,并订阅我的 Medium Newsletter。如果你是 Medium 新手,可以使用我的 会员推荐链接进行注册。
使用 Nvidia Triton Inference Server 部署 PyTorch 模型
原文:
towardsdatascience.com/deploying-pytorch-models-with-nvidia-triton-inference-server-bb139066a387
一个灵活且高性能的模型服务解决方案
·发表于 Towards Data Science ·7 分钟阅读·2023 年 9 月 14 日
--

图片来自 Unsplash
机器学习(ML)的价值在实际应用中真正体现,当我们到达 模型托管与推理时。如果没有一个高性能的模型服务解决方案,帮助你的模型实现弹性扩展,将很难将 ML 工作负载投入生产。
什么是模型服务器/什么是模型服务? 可以将模型服务器视为 ML 世界中与 web 服务器相当的东西。仅仅将大量硬件投入到模型中是不够的,你还需要一个通信层,帮助处理客户端的请求,同时有效分配硬件以应对你的应用程序所遇到的流量。模型服务器是一个可调节的功能:我们可以通过控制 gRPC 与 REST 等方面,从延迟角度挤压性能。流行的模型服务器示例如下:
今天我们探讨的是 Nvidia Triton Inference Server,这是一个高度灵活且高性能的模型服务解决方案。每个模型服务器要求以其特定的方式呈现模型工件和推理脚本,以便它可以理解。在今天的文章中,我们以一个示例 PyTorch 模型为例,展示如何利用 Triton Inference Server 进行托管。
注意:本文假设你对机器学习有基本了解,并且不深入探讨模型构建的理论。还假设你对 Python 有一定的熟练度,并对 Docker 容器有基本了解。我们还将在SageMaker 经典 Notebook 实例中进行开发,因此如有必要,请创建一个 AWS 账户(如果你愿意,也可以在其他地方运行此示例)。
免责声明:我是 AWS 的机器学习架构师,我的观点仅代表我自己。
为什么选择 Triton 推理服务器?
Triton 推理服务器是一个开源的模型服务解决方案,具有以下多种好处:
-
框架支持:Triton 原生支持多种框架,如 PyTorch、TensorFlow、ONNX,以及自定义的 Python/C++环境。Triton 在其设置中将这些不同的框架识别为“后端”。在这个例子中,我们使用它提供的 PyTorch 后端来托管我们的 TorchScript 模型。
-
动态批处理:推理性能调整是一个迭代实验。动态批处理是一种推理优化技术,通过这种技术,你可以将多个请求组合成一个。使用 Triton,你可以启用动态批处理并控制最大批量大小,以优化吞吐量和延迟。
-
模型集成/管道:通过集成和模型管道,你可以将多个机器学习模型及任何预处理/后处理逻辑组合成一个统一的执行流,放在一个容器后面。
-
硬件支持:Triton 支持基于 CPU/GPU 的工作负载。这使得它可以轻松配对像SageMaker 实时推理这样的托管平台,在这些平台上,你可以托管数千个模型,利用多模型端点与 GPU和 Triton 作为模型服务栈。
Triton 推理服务器与其他模型服务器一样,具有各自的优缺点,具体取决于用例,因此根据你的模型和托管需求,选择合适的模型服务器选项至关重要。
本地模型设置
在这个例子中,我们将使用 SageMaker 经典 Notebook 实例中的 PyTorch 内核。实例将是 g4dn.4xlarge,因此我们可以在基于 GPU 的实例上运行我们的 Triton 容器。
开始时,我们将使用一个样本 PyTorch 线性回归模型,并在由 numpy 生成的一些虚拟数据上进行训练。
# Dummy data
np.random.seed(0)
X = 2 * np.random.rand(100, 1)
y = 1 + 2 * X + np.random.randn(100, 1)
# Model
class LinearRegression(nn.Module):
def __init__(self):
super(LinearRegression, self).__init__()
self.linear = nn.Linear(1, 1)
def forward(self, x):
return self.linear(x)
# Training
for epoch in range(num_epochs):
# Forward pass
outputs = model(X_tensor)
loss = criterion(outputs, y_tensor)
# Backward pass and optimization
optimizer.zero_grad()
loss.backward()
optimizer.step()
然后我们将此模型保存为TorchScript模型,以便在 Triton PyTorch 后端中使用,并运行一个样本推理,以便了解模型推理的样本输入将是什么样的。
# save model as a torchscript model
torch.jit.save(torch.jit.script(model), 'model.pt')
# Load the saved model
loaded_model = torch.jit.load('model.pt')
# sample inference
test = torch.tensor([[2.5]])
pred = loaded_model(test)
print(pred)
现在我们有了 model.pt 并了解了如何用此模型进行推理,我们可以专注于设置 Triton 以托管这个特定的模型。
Triton 推理服务器托管
在我们可以开始设置 Triton 推理服务器之前,我们需要了解它需要哪些工件以及它期望的格式。我们已经有了 model.pt,这是我们的模型元数据文件。Triton 还需要一个 config.pbtxt 文件,基本上定义了你的服务属性。在这种情况下,我们定义了几个必要的字段:
-
model_name:这是我们模型库中的模型名称,你还可以为其版本控制,以防有多个版本的模型。
-
平台:这是服务器的后台环境,在这种情况下,我们将后台定义为 pytorch_libtorch,以设置 TorchScript 模型的环境。
-
输入/输出:对于 Triton,我们必须定义我们的输入/输出数据形状和格式(如果你不知道,可以通过 numpy 找到这些信息)
可选地,你还可以定义更高级的参数,如启用动态批处理、最大批大小,以及你希望为性能测试调整的后台特定变量。我们的基本 config.pbtxt 如下:
name: "linear_regression_model"
platform: "pytorch_libtorch"
input {
name: "input"
data_type: TYPE_FP32
dims: [ 1, 1 ]
}
output {
name: "output"
data_type: TYPE_FP32
dims: [ 1, 1 ]
}
Triton 还期望这个文件和 model.pt 以特定格式呈现,然后我们才能启动服务器。对于 PyTorch 后台,你的工件应该遵循以下模型库结构:
- models/
- linear_regression_model
- 1
- model.pt
- model.py (optional, not included here)
- config.pbtxt
- optional incldue any other models for example if you have an ensemble
然后我们使用以下 bash 命令将工件移到这个现有结构中:
mkdir linear_regression_model
mv config.pbtxt model.pt linear_regression_model
cd linear_regression_model
mkdir 1
mv model.pt 1/
cd ..
要启动 Triton 推理服务器,请确保你的环境中已安装Docker;这在 SageMaker Classic Notebook 实例中预装(在本文撰写时,SageMaker Studio 尚未预装)。
要启动容器,我们首先使用以下命令拉取最新的 Triton 镜像:
docker pull nvcr.io/nvidia/tritonserver:23.08-py3
然后我们可以利用Nvidia CLI实用程序命令结合 Docker 来启动我们的 Triton 推理服务器。Triton 有三个默认端口用于推理,我们在 Docker 运行命令中指定:
-
8000:HTTP REST API 请求,我们在这个示例中使用此端口
-
8002:通过 Prometheus 监控 Triton 推理服务器的指标
确保将提供的路径替换为你的模型库目录路径。在这个实例中,它是‘/home/ec2-user/SageMaker’,你需要将其替换为你的模型库路径。
docker run --gpus all --rm -p 8000:8000 -p 8001:8001 -p 8002:8002
-v /home/ec2-user/SageMaker:/models nvcr.io/nvidia/tritonserver:23.08-py3
tritonserver --model-repository=/models --exit-on-error=false --log-verbose=1
一旦我们运行这个命令,你应该会看到 Triton 推理服务器已经启动。

Triton 服务器已启动(截图由作者提供)
我们现在可以向模型服务器发出请求,我们可以通过两种不同的方式进行,这里将进行探索:
-
Python Request Library: 在这里你可以传入 Triton Server 地址的推理 URL 并指定输入参数。
-
Triton Client Library: Triton 提供的客户端,你可以实例化并使用其内置的 API 调用。在这种情况下我们使用 Python,但你也可以使用 Java 和 C++。
对于 requests 库,我们传入适当的 URL 和模型名称及版本,如下所示:
# sample data
input_data = np.array([[2.5]], dtype=np.float32)
# Specify the model name and version
model_name = "linear_regression_model" #specified in config.pbtxt
model_version = "1"
# Set the inference URL based on the Triton server's address
url = f"http://localhost:8000/v2/models/{model_name}/versions/{model_version}/infer"
# payload with input params
payload = {
"inputs": [
{
"name": "input", # what you named input in config.pbtxt
"datatype": "FP32",
"shape": input_data.shape,
"data": input_data.tolist(),
}
]
}
# sample invoke
response = requests.post(url, data=json.dumps(payload))
对于 Triton 客户端库,我们使用在配置文件中指定的相同值,但利用 HTTP 客户端特定的 API 调用。
import tritonclient.http as httpclient #pip install if needed
# setup triton inference client
client = httpclient.InferenceServerClient(url="localhost:8000")
# triton can infer the inputs from your config values
inputs = httpclient.InferInput("input", input_data.shape, datatype="FP32")
inputs.set_data_from_numpy(input_data) #we set a numpy array in this case
# output configuration
outputs = httpclient.InferRequestedOutput("output")
#sample inference
res = client.infer(model_name = "linear_regression_model", inputs=[inputs], outputs=[outputs],
)
inference_output = res.as_numpy('output') #serialize numpy output
额外资源与总结
[## triton-inference-server-examples/pytorch-backend/triton-pytorch-ann.ipynb at master ·…
Triton Inference Server PyTorch 示例。贡献者 RamVegiraju/triton-inference-server-examples 开发中…
示例的完整代码可以在上面的链接中找到。Triton Inference Server 是一种动态模型服务选项,可用于高级 ML 模型托管。有关更多 Triton 特定的示例,请参阅以下 Github 仓库。在接下来的文章中,我们将继续探讨如何利用不同的模型服务器来托管各种 ML 模型。
一如既往,谢谢你的阅读,欢迎随时留下反馈。
如果你喜欢这篇文章,欢迎通过 LinkedIn 与我联系,并订阅我的 Medium 通讯。如果你是 Medium 新用户,可以通过我的 会员推荐链接进行注册。
使用 Terraform 部署 SageMaker 端点
原文:
towardsdatascience.com/deploying-sagemaker-endpoints-with-terraform-3b09fb3e1d59
使用 Terraform 进行基础设施即代码
·发表于Towards Data Science ·阅读时间 7 分钟·2023 年 3 月 14 日
--

图片来自Unsplash,作者为Krishna Pandey
基础设施即代码(IaC)是优化和将你的资源和基础设施推向生产的重要概念。IaC 是一个古老的 DevOps/软件实践,具有几个关键好处:资源通过代码集中维护,从而优化了将架构推向生产所需的速度和协作。
像许多其他最佳软件实践一样,这也适用于你的机器学习工具和基础设施。在今天的文章中,我们将探讨如何利用一种被称为Terraform的 IaC 工具,在 SageMaker 端点上部署一个预训练的 SKLearn 模型进行推断。我们将探索如何创建一个可重复使用的模板,你可以根据需要更新你的资源/硬件。使用 Terraform,我们可以从将独立的笔记本和散落的 Python 文件集中到一个模板文件中来进行管理。
使用 SageMaker 进行基础设施即代码的另一种选择是 CloudFormation。如果这是你用例的首选工具,你可以参考这篇文章。请注意,Terraform 是云服务提供商无关的,它跨越了不同的云服务提供商,而 CloudFormation 专门用于 AWS 服务。
注意:如果你是 AWS 新手,确保你在以下链接注册一个账户,以便进行操作。同时,确保安装了AWS CLI以便与示例配合使用。本文假设你对 Terraform 有基本了解,如果需要入门指南,请查看这个指南,并参考以下安装说明。本文还假设你对 SageMaker 部署有中级了解,我建议你阅读这篇文章以深入理解部署/推断,我们将在本文中使用相同的模型并将其映射到 Terraform。
设置
如前所述,我们不会真正关注模型训练和构建的理论。我们将快速在内置的波士顿住房数据集上训练一个示例 SKLearn 模型,该数据集由包提供。
import pandas as pd
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.metrics import mean_squared_error
from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn import metrics
import joblib
#Load data
boston = datasets.load_boston()
df = pd.DataFrame(boston.data, columns = boston.feature_names)
df['MEDV'] = boston.target
#Split Model
X = df.drop(['MEDV'], axis = 1)
y = df['MEDV']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = .2, random_state = 42)
#Model Creation
lm = LinearRegression()
lm.fit(X_train,y_train)
with open('model.joblib', 'wb') as f:
joblib.dump(lm,f)
with open('model.joblib', 'rb') as f:
predictor = joblib.load(f)
print("Testing following input: ")
print(X_test[0:1])
sampInput = [[0.09178, 0.0, 4.05, 0.0, 0.51, 6.416, 84.1, 2.6463, 5.0, 296.0, 16.6, 395.5, 9.04]]
print(type(sampInput))
print(predictor.predict(sampInput))
在这里,我们快速验证本地模型是否按预期进行推断。脚本还会生成序列化模型工件,我们将把它提供给 SageMaker 进行部署。接下来,我们创建一个自定义推断脚本,该脚本基本上作为处理 SageMaker 端点的预处理/后处理的入口点脚本。
import joblib
import os
import json
"""
Deserialize fitted model
"""
def model_fn(model_dir):
model = joblib.load(os.path.join(model_dir, "model.joblib"))
return model
"""
input_fn
request_body: The body of the request sent to the model.
request_content_type: (string) specifies the format/variable type of the request
"""
def input_fn(request_body, request_content_type):
if request_content_type == 'application/json':
request_body = json.loads(request_body)
inpVar = request_body['Input']
return inpVar
else:
raise ValueError("This model only supports application/json input")
"""
predict_fn
input_data: returned array from input_fn above
model (sklearn model) returned model loaded from model_fn above
"""
def predict_fn(input_data, model):
return model.predict(input_data)
"""
output_fn
prediction: the returned value from predict_fn above
content_type: the content type the endpoint expects to be returned. Ex: JSON, string
"""
def output_fn(prediction, content_type):
res = int(prediction[0])
respJSON = {'Output': res}
return respJSON
接下来,我们将脚本和模型工件打包成 SageMaker 兼容的 tarball 格式。然后,我们将这个模型 tarball 上传到 S3 存储桶,因为这是 SageMaker 使用的所有工件的主要存储选项。
import boto3
import json
import os
import joblib
import pickle
import tarfile
import sagemaker
from sagemaker.estimator import Estimator
import time
from time import gmtime, strftime
import subprocess
#Setup
client = boto3.client(service_name="sagemaker")
runtime = boto3.client(service_name="sagemaker-runtime")
boto_session = boto3.session.Session()
s3 = boto_session.resource('s3')
region = boto_session.region_name
print(region)
sagemaker_session = sagemaker.Session()
role = "Replace with your SageMaker IAM Role"
#Build tar file with model data + inference code
bashCommand = "tar -cvpzf model.tar.gz model.joblib inference.py"
process = subprocess.Popen(bashCommand.split(), stdout=subprocess.PIPE)
output, error = process.communicate()
#Bucket for model artifacts
default_bucket = sagemaker_session.default_bucket()
print(default_bucket)
#Upload tar.gz to bucket
model_artifacts = f"s3://{default_bucket}/model.tar.gz"
response = s3.meta.client.upload_file('model.tar.gz', default_bucket, 'model.tar.gz')
Terraform 变量
在我们的模板文件(.tf)中,我们首先定义一种称为Terraform 变量的东西。具体来说,通过输入变量,你可以传递类似于函数/方法定义的参数的值。任何你不想硬编码的值,但也想赋予默认值的,可以以变量的格式指定。我们将为实时 SageMaker 端点定义的变量如下。
-
SageMaker IAM 角色 ARN:这是与 SageMaker 服务关联的角色,附加所有你将与服务进行的操作所需的策略。请注意,你也可以在 Terraform 中定义并引用角色。
-
容器:AWS 提供的深度学习容器或你自己构建的自定义容器来托管你的模型。
-
模型数据:我们上传到 S3 的预训练模型工件,这也可以是从 SageMaker 训练作业中产生的训练工件。
-
实例类型:你实时端点背后的硬件。如果你愿意,也可以将实例数量设为变量。
对于每个变量,你可以定义:类型、默认值和描述。
variable "sm-iam-role" {
type = string
default = "Add your SageMaker IAM Role ARN here"
description = "The IAM Role for SageMaker Endpoint Deployment"
}
variable "container-image" {
type = string
default = "683313688378.dkr.ecr.us-east-1.amazonaws.com/sagemaker-scikit-learn:0.23-1-cpu-py3"
description = "The container you are utilizing for your SageMaker Model"
}
variable "model-data" {
type = string
default = "s3://sagemaker-us-east-1-474422712127/model.tar.gz"
description = "The pre-trained model data/artifacts, replace this with your training job."
}
variable "instance-type" {
type = string
default = "ml.m5.xlarge"
description = "The instance behind the SageMaker Real-Time Endpoint"
}
尽管我们在本文中不会深入探讨,但你也可以在 SageMaker 中为不同的托管选项定义变量。例如,在无服务器推理中,你可以将内存大小和并发性定义为两个你想设置的变量。
variable "memory-size" {
type = number
default = 4096
description = "Memory size behind your Serverless Endpoint"
}
variable "concurrency" {
type = number
default = 2
description = "Concurrent requests for Serverless Endpoint"
}
Terraform 资源与部署
最基本的 Terraform 构建块是资源。在资源块中,你实际上定义了一个基础设施对象。对于我们的用例,我们具体有三个 SageMaker 构建块:SageMaker 模型、SageMaker 端点配置和 SageMaker 端点。这些构建块彼此相连,最终帮助我们创建所需的端点。
我们可以参考 Terraform 文档中的SageMaker 模型以开始。首先,我们定义资源本身,其中包含两个组件:资源的 Terraform 名称以及你定义的用于在模板中稍后引用的名称。另一个关键部分是我们如何使用 Terraform 关键字var来引用变量值。
# SageMaker Model Object
resource "aws_sagemaker_model" "sagemaker_model" {
name = "sagemaker-model-sklearn"
execution_role_arn = var.sm-iam-role
接下来,对于我们的 SageMaker 模型,我们定义之前定义的容器和模型数据,并引用这些特定变量。
primary_container {
image = var.container-image
mode = "SingleModel"
model_data_url = var.model-data
environment = {
"SAGEMAKER_PROGRAM" = "inference.py"
"SAGEMAKER_SUBMIT_DIRECTORY" = var.model-data
}
}
可选地,在 SageMaker 中,你还可以为特定对象提供一个你定义的标签。
tags = {
Name = "sagemaker-model-terraform"
}
我们对端点配置采用类似的格式,在这里我们实际上定义了我们的硬件。
# Create SageMaker endpoint configuration
resource "aws_sagemaker_endpoint_configuration" "sagemaker_endpoint_configuration" {
name = "sagemaker-endpoint-configuration-sklearn"
production_variants {
initial_instance_count = 1
instance_type = var.instance-type
model_name = aws_sagemaker_model.sagemaker_model.name
variant_name = "AllTraffic"
}
tags = {
Name = "sagemaker-endpoint-configuration-terraform"
}
}
然后我们在端点创建中引用这个对象。
# Create SageMaker Real-Time Endpoint
resource "aws_sagemaker_endpoint" "sagemaker_endpoint" {
name = "sagemaker-endpoint-sklearn"
endpoint_config_name = aws_sagemaker_endpoint_configuration.sagemaker_endpoint_configuration.name
tags = {
Name = "sagemaker-endpoint-terraform"
}
}
在我们可以部署模板以配置资源之前,请确保你已经通过以下命令配置了 AWS CLI。
aws configure
然后我们可以通过以下命令初始化我们的 Terraform 项目。
terraform init
对于部署,我们可以运行另一个 Terraform CLI 命令。
terraform apply

资源创建(作者截图)
在端点创建过程中,你也可以通过 SageMaker 控制台验证这一点。

端点创建 SM 控制台(作者截图)
额外资源与结论
[## IaC-SageMaker-Deployment/Terraform at master · RamVegiraju/IaC-SageMaker-Deployment
你目前无法执行该操作。你在另一个标签页或窗口中已登录。你在另一个标签页或窗口中已登出…
示例的整个代码可以在上述代码库中找到。希望这篇文章能为大家提供一个关于 Terraform 的总体介绍,以及与 SageMaker 推理的使用情况。基础设施即代码(Infrastructure as Code)是一项在 MLOps 世界中扩展到生产环境时不可忽视的关键实践。
如果你喜欢这篇文章,可以通过 LinkedIn 与我联系,并订阅我的 Medium Newsletter。如果你是 Medium 的新用户,可以通过我的 会员推荐链接进行注册。
在 Power BI 中与 sklearn 机器学习模型互动
原文:
towardsdatascience.com/deploying-sklearn-models-in-power-bi-d982f2d21ec
·发布于Towards Data Science ·9 分钟阅读·2023 年 5 月 24 日
--
在某些情况下,我们希望拥有一个可以操作的监督学习模型。虽然任何数据科学家都可以很容易地在 Jupyter notebook 中构建一个 SKLearn 模型并进行操作,但当你希望其他利益相关者与模型互动时,你需要创建一个前端。这可以通过一个简单的 Flask webapp 来完成,提供一个网络界面,供用户向 sklearn 模型或管道输入数据以查看预测输出。然而,这篇文章将重点介绍如何在 Power BI 中使用 Python 可视化与模型互动。
这篇文章将包括两个主要部分:
-
构建 SKLearn 模型 / 构建管道
-
构建 Power BI 接口
代码非常简单,你可以从这篇文章中复制你需要的部分,但它也可以在我的 Github上找到。要使用它,你需要做两件事。运行 Python Notebook 中的代码以序列化管道,并在 Power BI 文件中修改管道路径。
1. 构建模型
在这个例子中,我们将使用 Titanic 数据集并构建一个简单的预测模型。这个模型将是一个分类模型,使用一个分类特征(‘sex’)和一个数值特征(‘age’)作为预测变量。为了演示这种方法,我们将使用 RandomForestClassifier 作为分类模型。这是因为 Random Forest 分类器在 Power BI 中的实现比例如在 MQuery 或 DAX 中编码的逻辑回归要复杂一些。此外,由于这篇文章并不是为了构建最好的模型,我会相当依赖于scikit-learn 文档的部分内容,而且不会过多关注性能。
我们创建的代码做了几件事。首先,它加载并预处理 Titanic 数据集。如前所述,我们仅使用“性别”和“年龄”特征,但这些特征仍需处理。分类变量“性别”必须转换为虚拟变量或进行独热编码(即将一列重新编码为一组列),以便任何 sklearn 模型能够处理它。对于数值特征“年龄”,我们进行标准的 MinMaxScaling,因为它的范围大约是 0 到 80,而“性别”的范围是 0 到 1。一旦完成所有这些步骤,我们删除所有缺失值的观察数据,进行训练/测试拆分,然后构建和序列化管道。
#Imports
from sklearn.datasets import fetch_openml
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
#Load the dataset
X,y = fetch_openml("titanic", version = 1, as_frame=True, return_X_y=True)
#Create the OneHotEncoding for the categorical variable 'sex'
categorical_feature = ["sex"]
categorical_transformer = Pipeline(
steps = [
("encoder",OneHotEncoder(drop="first"))
])
preprocessor = ColumnTransformer(
transformers = [
("categorical", categorical_transformer, categorical_feature)
])
#Creating the Pipeline, with preprocessing and the Random Forest Classifier
clf = Pipeline(
steps = [
("preprocessor", preprocessor),
("classifier", RandomForestClassifier())
]
)
#Select only age and sex as predictors
X = X[["age","sex"]]
#Drop rows with missing values
X = X.dropna()
#Keep only observations corresponding to rows without missing values
y = y[X.index]
#Create Train/Test Split
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=1)
#Fit the Pipeline
clf.fit(X_train, y_train)
#Score the Pipeline
y_pred = clf.predict(X_test)
print(classification_report(y_test, y_pred))
上述代码创建了一个得分不太高但足够满足本帖目的的模型。
precision recall f1-score support
0 0.85 0.84 0.85 159
1 0.76 0.77 0.76 103
accuracy 0.81 262
macro avg 0.80 0.80 0.80 262
weighted avg 0.81 0.81 0.81 262
后续对我们有帮助的是检查模型的预测。为此,我们创建一个包含年龄和性别的笛卡尔积的 DataFrame(即所有可能的“年龄”/“性别”组合)。我们利用这个 DataFrame 从管道中计算预测,然后将这些预测绘制成热图。实现这一功能的代码如下。
from pandas import DataFrame
# Create a DataFrame with all possible ages
ages = DataFrame({'age':range(1,80,1)})
# Create a DataFrame with all possible sexes
sexes = DataFrame({'sex':["male","female"]})
# Create a DataFrame with all possible combinations.
combinations = ages.merge(sexes, how='cross')
# Predict survival for combinations
combiations["predicted_survival"] = clf.predict(combinations)
# Plot the Heatmap
sns.heatmap(pd.pivot_table(results, values="predicted_survival", index=["age"],columns=["sex"]), annot=True)
相应的热图如下所示,它显示了例如 13–33 岁的女性的预测结果是生存(1)。而年龄恰好为 37 岁的女性被预测为不生存。对于男性,预测大多是不生存,除非年龄为 12 岁及一些更年轻的年龄。此信息在调试 Power BI 报告时将非常有用。

热图展示了不同年龄(垂直)和性别(水平)组合的模型预测。白色方块代表生存的预测,黑色方块代表去世的预测。总体上,我们看到女性乘客的生存预测概率较高。
现在完成了这些,我们可以序列化模型,以便将其嵌入到 Power BI 报告中。
from joblib import dump
dump(clf, "randomforest.joblib")
2. Power BI 界面
创建 Power BI 界面包括两个步骤。第一步是创建控件以将数据输入模型。第二步是创建可视化,将控件中的输入数据传递到模型中并显示预测结果。
2a. 控件
使用 Power BI 时需要了解几个重要概念。首先是参数,即包含值的变量。这些参数可以通过切片器控制,它们的值可以通过 Power BI 中的可视化元素访问,在我们的案例中是 Python 可视化。

添加年龄切片器的过程
对于参数来说,至关重要的是我们保持与进入管道的数据相同的结构和值。因此,在我们的情况下,我们需要一个用于年龄的控制(一个从 0 到 80 的数值变量)和一个用于性别的控制(一个具有“男性”和“女性”两个值的分类变量)。创建“年龄”参数和切片器非常简单。在功能区的“建模”部分,我们使用“新建参数”按钮,在下拉菜单中选择“数值”选项,并指定我们希望能够输入的值。我们确保勾选“添加切片器”复选框,完成后,第一个控制项和对应的参数就可用了。
对于“性别”,这会稍微繁琐一些。首先需要创建一个包含所有可能值的表格。最优雅的方式是通过 DAX 完成。通过点击功能区“建模”部分的“新建表格”按钮,并输入以下文本来实现。这条查询创建了一个名为“SexValues”的新表格,其中包含一个名为“性别值”的字符串列,列值为“男性”和“女性”。这些将用于创建参数。
SexValues = DATATABLE("Sex Values",String,{{"male"},{"female"}})

通过在功能区“建模”部分的“新建参数”按钮下拉菜单中的“字段”选项来创建新的参数。在这个参数的配置中,我们从我们制作的表格(SexValues)中选择“性别值”字段。确保在对话框中开启了“添加切片器”。按下确定后,切片器将被添加到您的 Power BI 报告中,但还需要一些额外的设置。选择切片器,并使用界面中显示视觉属性的部分。在指示字段的下拉菜单中,点击向下的箭头,选择“显示所选字段的值”。完成后,所有控制项都准备好了,所有参数都已配置,我们可以开始输入 Python 可视化。
2b. 创建可视化

现在所有数据都已经到位,接下来是创建 Python 可视化。为此,创建一个 Python 可视化。使用‘Py’按钮创建可视化,并选择参数(‘性别’和‘年龄值’)作为输入。对于 Python 可视化,来自参数的信息会以 pandas.DataFrame 的形式提供,其中包含一行,参数名称(‘年龄值’和‘性别值’)作为列名。代码会经过若干步骤来使用这些信息。首先,我们导入所有必要的库,包括 joblib、相关的 sklearn 库、pandas 和用于可视化的 matplotlib。完成后,加载序列化的管道,参数数据集被修改以对应于用于训练模型的数据集。之后,管道用于根据参数值预测生存情况,预测结果及其参数值会在 matplotlib 可视化中打印出来。
# The following code to create a dataframe and remove duplicated rows is always executed and acts as a preamble for your script:
# dataset = pandas.DataFrame(Sex Values, Age Value)
# dataset = dataset.drop_duplicates()
# Paste or type your script code here:
# Imports
from joblib import load
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
import pandas as pd
import matplotlib.pyplot as plt
# Loading the serialized Pipeline - Make sure to point it to where you serialized the pipeline
clf = load(r"\randomforest.joblib")
# Rename the dataset with the Parameter names to match the original column names.
dataset = dataset.rename(columns={"Age Value":"age", "Sex Values":"sex"})
# Make the predictions
dataset["PredictedSurvival"] = clf.predict(dataset)
# Output the predictions
fig = plt.figure()
ax = fig.add_subplot()
ax.axis([0, 1, 0, 1])
plt.grid(False)
plt.axis('off')
ax.text(0, 0.8, "submitted age: " + str(dataset.iloc[0,0]), fontsize=25)
ax.text(0, 0.6, "submitted sex: " + str(dataset.iloc[0,1]), fontsize=25)
ax.text(0, 0.2, "predicted survival: " + str(dataset.iloc[0,2]), fontsize=30)
plt.show()
一旦一切完成,你可以稍微移动一下元素,让它看起来更好,或者如果有时间的话,可以改变整个报告的设计以及 Python 可视化。但呐呐呐,如果你现在玩弄切片器,你会看到预测被更新。

为了验证模型是否按预期工作,我们可以检查构建模型后记住的值,以确认 Power BI 可视化是否确实与我们从数据中预期的结果相符。确实,提交之前找到的值显示,生存预测的变化符合预期。

来自 Power BI 中模型的预测
结论
这是一个相当简单的、人工的用例,目的是构建一个 sklearn 模型并在 Power BI 中与该模型互动。对于这个用例来说,这有点过于复杂,因为预计算所有年龄和性别组合的预测结果,并将其加载到 Power BI 中,会更容易、更快捷且更灵活。可惜的是,这种方法只适用于有限数量的特征,而这里描述的方法原则上可以扩展到具有更多特征的模型。
这种方法的一个负面方面是,接口的性能相当低,这可能是因为每次参数值发生变化时,整个管道都必须重新反序列化、加载并重新预测。
我非常希望了解这种方法是否对任何人有用。你能想到哪些用例?如果你有任何问题、想法或建议,我非常乐意听取并一起思考!
在 GCP 无服务器架构上部署 TFLite 模型
原文:
towardsdatascience.com/deploying-tflite-model-on-gcp-serverless-b4cd84f86de1
如何以无服务器的方式部署量化模型
·发布于 Towards Data Science ·阅读时长 11 分钟·2023 年 7 月 21 日
--
模型部署是一个棘手的问题;由于云平台和其他 AI 相关库的不断变化,几乎每周都有更新,因此向后兼容性和找到正确的部署方法是一个巨大的挑战。在今天的博客文章中,我们将探讨如何以无服务器的方式在Google Cloud Platform上部署tflite 模型。
本博客文章的结构如下:
-
理解无服务器架构和其他部署方式
-
什么是量化和 TFLite?
-
使用 GCP Cloud Run API 部署 TFLite 模型

图片来源: pixabay.com/photos/man-pier-silhouette-sunrise-fog-8091933/
理解无服务器架构和其他部署方式
首先让我们了解什么是无服务器架构,因为无服务器并不意味着没有服务器。
一个 AI 模型,或任何应用程序,实际上可以通过多种不同的方式进行部署,主要有三大类。
无服务器架构: 在这种情况下,模型存储在云容器注册表中,只有在用户发出请求时才会运行。当请求发出时,会自动启动一个服务器实例来处理用户请求,并在一段时间后关闭。从启动、配置、扩展到关闭,这一切都由 Google Cloud 平台提供的 Cloud Run API 处理。在其他云平台中,我们有 AWS Lambda 和 Azure Functions 作为替代方案。
无服务器架构有其自身的优缺点。
-
最大的优势在于节省成本,如果你没有大量的用户基础,大部分时间服务器处于闲置状态,你的钱只是白白花费了。另一个优势是我们不需要考虑扩展基础设施,根据服务器的负载,它可以自动复制实例的数量并处理流量。
-
在缺点方面,有三点需要考虑。首先是小负载限制,这意味着它不能用于运行更大的模型。其次,服务器在 15 分钟空闲后会自动关闭,因此当我们在很长时间后发出请求时,第一次请求比后续请求花费的时间要长,这个问题被称为冷启动问题。最后,目前还没有适当的 GPU 基于实例可用于无服务器计算。
服务器实例: 在这种模式中,服务器始终处于运行状态,即使没有人请求我们的应用,你也总是需要支付费用。对于用户基础较大的应用来说,保持服务器持续运行是很重要的。在这种策略下,我们可以以多种方式部署应用,其中一种方式是启动一个单一的服务器实例,并在流量增加时手动扩展。实际上,这些服务器是借助Kubernetes 集群启动的,这些集群定义了扩展基础设施的规则,并为我们进行流量管理。
- 最大的优势在于我们可以使用最大规模的模型和应用,并精确控制我们的资源,从基于 GPU 的实例到常规实例。但正确管理和扩展这些服务器实例是一项相当大的任务,通常需要大量的调整。这些对于基于 GPU 的实例来说可能非常昂贵,因为许多 AI 模型需要 GPU 以实现更快的推理。
理解 Kubernetes 和 Docker 的两个极好的资源:
🚀 和我一起在 15 分钟内将一个 hello-world 节点应用程序 Docker 化。
medium.com](https://medium.com/aiguys/docker-for-dummies-8e8edc8af0ea?source=post_page-----b4cd84f86de1--------------------------------) [## Kubernetes 101:容器编排介绍 🎵 🐳
如果你正在阅读这篇文章,你很可能对容器化、镜像等概念非常熟悉……
边缘部署: 当我们需要在没有互联网的地方获得最快响应时,我们选择边缘部署。这种部署类型适用于IoT 设备和其他没有大内存或互联网连接的小型设备。例如,如果我们希望在无人机上使用 AI,我们希望 AI 模块部署在无人机本身上,而不是某个云服务器上。
- 由于设备的硬件限制,这种部署类型只能处理非常小的负载。在这种部署模式下,没有成本,因为一切都在本地运行。使模型小到足以适应 IoT 设备是相当具有挑战性的,并且需要完全不同的策略。
部署策略有很多内容;在一个博客中几乎不可能覆盖所有内容。这里有另一个很好的博客提供了整个 MLOPS 策略的概述。
模型构建很出色,但如果我们不能部署这些模型,它们就会变得无用。与深度学习不同,找到……
什么是量化和 TFLite?
量化是一种模型压缩技术,在这种技术中,我们将权重转换为较低的精度,以减小模型的大小,从而使模型在推断时更小、更快。量化可以显著提高速度,并且通常用于边缘部署。在无服务器模式下部署量化模型可以大大节省成本,因为这使得 AI 模型小到足以在无服务器模式下使用。
注意: 人们常常认为需要 GPU 实例来服务 AI 模型,因为他们用 GPU 实例训练了这些模型,但这并不正确。大多数 AI 应用程序通过 CPU 实例和适当的部署策略可以服务甚至十亿用户。
量化是压缩模型大小的众多方法之一,还有很多其他方法,如剪枝、权重共享等。
这里有一篇文章详细介绍了所有的模型压缩技术:
随着每年模型变得越来越复杂和庞大。很多在研究实验室开发的 AI 模型从未……
什么是 TFLite
根据 TensorFlow 网站,“TensorFlow Lite 是一套工具,通过帮助开发者在移动设备、嵌入式设备和边缘设备上运行模型,实现设备上的机器学习。”
量化 AI 模型的方法有很多;主要分为 训练后量化和量化感知训练 两类。在前者中,我们通常先训练模型。训练完成后,对模型权重应用量化,而在后者中,量化在训练过程中就已经激活。通常,量化感知训练的效果优于训练后量化。
让我们直接跳到量化代码部分。我们在这个博客中使用的是训练后量化的图像分割模型。下图显示了我们 AI 管道的架构。

AI 管道架构(图片来源:作者所有)
我在这里做出以下假设:
- 您已经有一个以 .hdf5 或 .h5 格式保存的图像分割模型。
如果不是,请参阅 Keras 官方网站的教程:keras.io/examples/vision/oxford_pets_image_segmentation/
-
您有一个名为 train_input_img_paths 的变量,用于存储所有训练图像的路径。您可以再次参阅 Keras 官方示例链接的第 1 步。
-
如果您有自己的自定义数据加载器,请修改 represetative_dataset() 方法。
import tensorflow as tf
## Load your tensorflow model
model = tf.keras.models.load_model("your_model.hdf5")
# Convert the model to the TensorFlow Lite format with float16 quantization
def representative_dataset():
for j in range(0, len(train_input_img_paths) // batch_size):
x_train, _ = train_gen.__getitem__(j)
yield [x_train.astype(np.float32)]
converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset
converter.target_spec.supported_types = [tf.float16]
tflite_quant_model = converter.convert()
# Save the quantized model to file
with open('post_training_quantization/model_quantized_float16.tflite', 'wb') as f:
f.write(tflite_quant_model)
现在,我们准备使用 Google Cloud Run API 以无服务器的方式部署我们的 TFLite 模型。
使用 GCP Cloud Run API 部署 TFLite 模型
我们需要这些资源和文件来部署我们的模型并进行预测。
-
Dockerfile
-
app.py
-
client.py
-
requirements.txt
-
量化模型
首先,我们来理解部署的流程。
无服务器部署流程从 容器化 应用程序 app.py(我们在这里使用 Docker)开始,然后 将 Docker 镜像推送到容器注册表(在我们这里是 Google 容器注册表);我们需要容器注册表来确保我们镜像的版本控制、可用性和安全性。接着,将其配置并部署到无服务器平台(Google Cloud Run API),然后让平台处理我们的函数的执行和扩展。
无服务器模式的部署将基础设施管理抽象化,并提供自动扩展,使我们能够有更多时间专注于开发和部署应用程序代码。
Dockerfile
FROM python:3.9-slim
# Set the working directory inside the Docker image
WORKDIR /app
# Copy the requirements.txt file to the working directory
COPY requirements.txt ./requirements.txt
# Install the required Python packages specified in requirements.txt
RUN pip install -r requirements.txt
# Copy the pre-trained model file from your local machine to the Docker image
COPY model_quantized_float16.tflite ./post_training_quantization/model_quantized_float16.tflite
# Copy the entire content of the current directory to the working directory inside the Docker image
COPY . .
# Specify the command to run when the Docker container starts
CMD ["python", "app.py"]
总体而言,这个 Dockerfile 设置了运行 Flask 应用程序(app.py)所需的环境和依赖项。它确保在容器内可以使用所需的 Python 包和预训练模型文件。
app.py
from flask import Flask, request, jsonify
from PIL import Image
import tensorflow as tf
import numpy as np
import io
app = Flask(__name__)
# Load the pre-trained TensorFlow Lite model
model = tf.lite.Interpreter(model_path="post_training_quantization/model_quantized_float16.tflite")
model.allocate_tensors()
@app.route('/predict', methods=['POST'])
def predict():
"""
Endpoint for making predictions.
Expects a POST request with an image file in the 'file' field.
Returns a JSON response with the predicted result.
"""
# Read the image file from the request
data = request.files['file'].read()
# Open and resize the image using Pillow (PIL)
image = Image.open(io.BytesIO(data)).resize((128, 128))
# Convert the image to a NumPy array
image = np.array(image) # RGB
# Convert RGB to BGR (required by the model)
image = image[:, :, ::-1]
# Normalize the image by dividing by 255.0
image = image / 255.0
# Get input and output details of the TensorFlow Lite model
input_details = model.get_input_details()
output_details = model.get_output_details()
# Expand dimensions of the image to match the input shape of the model
image = np.expand_dims(image, axis=0).astype(input_details[0]['dtype'])
# Set the input tensor of the model
model.set_tensor(input_details[0]['index'], image)
# Run the model inference
model.invoke()
# Get the output tensor of the model
output_data = model.get_tensor(output_details[0]['index'])
# Convert the output from a NumPy array to a Python list
output_data_list = output_data.tolist()
# Return the predicted result as a JSON response
return jsonify({"result": output_data_list})
if __name__ == '__main__':
# Run the Flask app on the specified host and port
app.run(host='0.0.0.0', port=8080)
client.py
import requests
import numpy as np
import matplotlib.pyplot as plt
import json
import time
# Use the URL of your deployed application
url = 'put_your_http_url_which_youll_get_after_successfull_deployment/predict'
# Open your image file in binary mode
with open('test_image.jpg', 'rb') as img_file:
file_dict = {'file': img_file}
start_time = time.time() # Start measuring the time
# Make a POST request to the server
response = requests.post(url, files=file_dict)
end_time = time.time() # Stop measuring the time
# The response will contain the segmented image data and shape
response_dict = json.loads(response.text)
# Convert result list to numpy array. Adjust dtype according to your model's output.
segmented_image_array = np.array(response_dict['result'], dtype=np.float16)
elapsed_time = end_time - start_time
print(f"Request completed in {elapsed_time:.2f} seconds")
# Plot the image using matplotlib
plt.imshow(segmented_image_array.squeeze(), cmap='gray') # use squeeze to remove single-dimensional entries from the shape of an array.
plt.show()
注意: 当我训练图像分割模型时,我使用了 BGR 格式(OpenCV 的默认模式);如果您使用了 RGB,请从 app.py 中删除 第 30 行。
此外,请在 client.py 的 第 8 行 中填入您在成功部署 Google Cloud RUN API 后获得的端点 URL。
最后,为了避免在部署过程中出现问题,请在 Dockerfile 和本地环境中使用相同版本的 Python。
requirements.txt
flask==2.0.1
jinja2==3.0.1
tensorflow==2.10.1
Pillow
量化模型
最后,我们需要将 model_quantized_float16.tflite 保存在与 app.py 相同的文件夹中,因为我们将量化模型复制到 Docker 镜像中。
这是我收集所有资源后目录的样子:

图像来源:属于作者
设置无服务器环境
- 第一步是获取 gcloud CLI(命令行接口),我使用了 Windows,操作非常简单:
cloud.google.com/sdk/docs/install
2. 使用标准 CLI 命令导航到你的文件夹
cd path_to_folder
3. 登录 gcloud CLI
gcloud auth login
这会在浏览器中打开一个窗口,并要求授予一些权限,请允许。
4. 在 gcloud 中设置项目,最好使用 GUI 界面。
这是创建 GCP 项目的链接:
[## 创建 Google Cloud 项目 | Google Workspace | Google 开发者
使用 Google Workspace API 和构建 Google Workspace 插件或应用程序需要一个 Google Cloud 项目。这...
developers.google.com](https://developers.google.com/workspace/guides/create-project?source=post_page-----b4cd84f86de1--------------------------------) 
GCP 项目仪表板(图像来源:属于作者)
5. 在 gcloud CLI 中设置项目 ID,你可以在仪表板中看到你的项目 ID。
gcloud config set project PROJECT_ID
6. 在 gcloud CLI 中构建容器。将 <PROJECT_ID> 替换为实际项目 ID。
docker build -t gcr.io/<PROJECT_ID>/tflite-app .

构建容器(图像来源:属于作者)
7. 通过 gcloud CLI 将 Docker 镜像推送到容器注册表
docker push gcr.io/<PROJECT_ID>/tflite-app

Google 容器注册表(图像来源:属于作者)
8. 通过 gcloud CLI 部署 Cloud RUN API。这会要求选择服务器位置和一些其他的身份验证,允许所有这些操作。
gcloud run deploy tflite-service --image gcr.io/<PROJECT_ID>/tflite-app --platform managed

模型已部署(图像来源:属于作者)
如果一切顺利,你将在 gcloud CLI 中看到一个链接,你需要将其粘贴到 client.py 中。否则,请查看日志并尝试修复错误。

Cloud Run API 控制台(图像来源:属于作者)
需要注意的关键点:
几乎可以保证在这个部署过程中会出现一些问题;最大的问题是包的版本不匹配。
在 requirements.txt 和 Dockerfile 中使用与你训练模型和量化模型时完全相同的版本。记住 GCP 的 TF 和 Python 版本通常较旧,最好使用较旧的版本。
我在 Python 3.8.15 上训练了我的模型;其余的在 requirements.txt 中给出。日志中的错误通常不明确,因此请始终使用完全相同的版本;如果在 GCP 中找不到所需的版本,请为本地环境更改版本。
接下来,部署失败的最大原因是你没有激活所需的 API 或者你没有必要的权限和 IAM 角色。如果你第一次使用 GCP,最好使用拥有所有权限的账户作为所有者。
进行预测
只需在你的 gcloud CLI 或标准命令提示符下运行 client.py。
这是我的输出示例:

模型预测(图片来源:作者提供)
我在一些私人数据上训练了一个二分类图像分割模型。由于隐私原因,我不能透露我的模型或数据的详细信息。但所有提到的内容都应该适用于任何图像分割模型。
版本控制
最后,如果你需要从一开始就获得更多资源,或想要最小化冷启动问题,我们可以通过几个额外的步骤创建同样的新版本。
前往你的 gcloud 控制台 在 GUI > 搜索 cloud run API > 选择已部署的服务 > 点击编辑并部署新版本按钮。你将看到以下选项,根据你的需求进行选择,保存它们,系统将自动为下一批请求设置模型的新版本。

解决冷启动问题(图片来源:作者提供)
结论
-
选择正确的部署策略对节省成本至关重要。
-
我们可以使用量化技术使模型更快更小。
-
使用量化模型的无服务器部署是一种很好的策略,可以轻松处理许多请求而不使用昂贵的 GPU 实例。
-
无服务器架构消除了扩展的麻烦。
感谢你的时间和耐心,祝学习愉快 ❤。关注我,获取更多这样的精彩内容。
这是我的 MLOps 阅读清单,讨论了几个其他关键概念和策略:

MLOps
查看列表10 个故事


使用 Python 进行深度感知的对象插入视频
使用 Python 进行深度感知的 3D 模型插入视频的指南
·
关注 发表在 Towards Data Science ·9 分钟阅读·2023 年 8 月 29 日
--
作者提供的图片
在计算机视觉领域,一致的深度和相机姿态估计为更高级的操作奠定了基础,例如在视频中插入深度感知对象。基于我之前探讨这些基本技术的文章,本文将重点转向深度感知对象的插入。通过基于 Python 的计算方法,我将概述一种将对象添加到现有视频帧中的策略,以符合深度和相机方向数据。这种方法不仅提升了编辑视频内容的真实感,还有广泛的视频后期制作应用。
总结来说,该方法包括两个主要步骤:首先,估计视频中的一致深度和相机位置;其次,将网格对象叠加到视频帧上。为了使对象在视频的三维空间中看起来是静止的,它会沿着相反的方向移动以抵消相机的移动。这种反向移动确保对象在整个视频中看起来像是固定的。
你可以在我的 GitHub 页面查看我的代码,本文中将引用这些代码。
步骤 1:生成相机姿态矩阵和视频的一致深度估计
在我之前的文章中,我详细解释了如何估计视频的一致深度帧及其对应的相机姿态矩阵。
对于这篇文章,我选择了一段视频,视频内容是一个人在街上行走,特别选择了这一视频是因为其明显的相机运动轴线。这将有助于清楚地评估插入的对象是否在视频的三维空间中保持固定位置。
我按照我在之前的文章中解释的所有步骤获取了深度帧和估计的相机姿态矩阵。我们特别需要由 COLMAP 生成的“custom.matrices.txt”文件。
原始视频及其估计深度视频如下所示。

(左) 由Videvo提供的库存视频,从www.videvo.net下载 | (右) 作者创建的估计深度视频
对应于第一帧的点云可视化如下。白色间隙表示由于前景对象的存在而被相机视角遮挡的阴影区域。

生成的视频第一帧的点云
步骤 2:选择你想插入的网格文件
现在,我们选择要插入到视频序列中的网格文件。各种平台,如Sketchfab.com和GrabCAD.com,提供了丰富的 3D 模型供选择。
对于我的演示视频,我选择了两个 3D 模型,相关链接在下图说明中提供:

(左) 3D 模型由 Abby Gancz 提供(CC BY 4.0),下载自www.sketchfab.com | (右) 3D 模型由 Renafox 提供(CC BY 4.0),下载自www.sketchfab.com
我使用了CloudCompare,这是一个用于 3D 点云处理的开源工具,对 3D 模型进行了预处理。具体而言,我去除了对象的地面部分,以增强其在视频中的整合效果。虽然这一步是可选的,但如果你希望修改你的 3D 模型的某些方面,强烈推荐使用 CloudCompare。
处理完网格文件后,将其保存为.ply 或.obj 文件。(请注意,并非所有的 3D 模型文件扩展名都支持彩色网格,例如.stl)。
第 3 步:重新渲染带有深度感知物体插入的帧
我们现在来到了项目的核心部分:视频处理。在我的仓库中,提供了两个关键脚本——video_processing_utils.py和depth_aware_object_insertion.py。顾名思义,video_processing_utils.py包含了所有用于物体插入的必要功能,而depth_aware_object_insertion.py作为主要脚本,在循环中对每一帧视频执行这些功能。
下面是depth_aware_object_insertion.py主要部分的一个片段。在一个循环中,该循环运行的次数与输入视频中的帧数相同,我们从深度计算管道中加载批量信息,从中获取原始 RGB 帧及其深度估计。然后我们计算相机姿态矩阵的逆。接下来,我们将网格、深度和相机的内参输入名为render_mesh_with_depth()的函数中。
for i in tqdm(range(batch_count)):
batch = np.load(os.path.join(BATCH_DIRECTORY, file_names[i]))
# ... (snipped for brevity)
# transformation of the mesh with the inverse camera extrinsics
frame_transformation = np.vstack(np.split(extrinsics_data[i],4))
inverse_frame_transformation = np.empty((4, 4))
inverse_frame_transformation[:3, :] = np.concatenate((np.linalg.inv(frame_transformation[:3,:3]),
np.expand_dims(-1 * frame_transformation[:3,3],0).T), axi
inverse_frame_transformation[3, :] = [0.00, 0.00, 0.00, 1.00]
mesh.transform(inverse_frame_transformation)
# ... (snipped for brevity)
image = np.transpose(batch['img_1'], (2, 3, 1, 0))[:,:,:,0]
depth = np.transpose(batch['depth'], (2, 3, 1, 0))[:,:,0,0]
# ... (snipped for brevity)
# rendering the color and depth buffer of the transformed mesh in the image domain
mesh_color_buffer, mesh_depth_buffer = render_mesh_with_depth(np.array(mesh.vertices),
np.array(mesh.vertex_colors),
np.array(mesh.triangles),
depth, intrinsics)
# depth-aware overlaying of the mesh and the original image
combined_frame, combined_depth = combine_frames(image, mesh_color_buffer, depth, mesh_depth_buffer)
# ... (snipped for brevity)
render_mesh_with_depth函数接受一个由顶点、顶点颜色和三角形表示的 3D 网格,并将其渲染到 2D 深度帧上。函数首先通过初始化深度和颜色缓冲区来保存渲染输出。然后,使用相机内参将 3D 网格顶点投影到 2D 帧上。函数使用扫描线渲染来循环遍历网格中的每个三角形,并将其光栅化到 2D 帧上的像素中。在此过程中,函数计算每个像素的重心坐标以插值深度和颜色值。然后,仅当像素的插值深度比深度缓冲区中的现有值更接近相机时,使用这些插值值更新深度和颜色缓冲区。最后,函数将色彩和深度缓冲区作为渲染输出返回,其中色彩缓冲区转换为适合图像显示的 uint8 格式。
def render_mesh_with_depth(mesh_vertices, vertex_colors, triangles, depth_frame, intrinsic):
vertex_colors = np.asarray(vertex_colors)
# Initialize depth and color buffers
buffer_width, buffer_height = depth_frame.shape[1], depth_frame.shape[0]
mesh_depth_buffer = np.ones((buffer_height, buffer_width)) * np.inf
# Project 3D vertices to 2D image coordinates
vertices_homogeneous = np.hstack((mesh_vertices, np.ones((mesh_vertices.shape[0], 1))))
camera_coords = vertices_homogeneous.T[:-1,:]
projected_vertices = intrinsic @ camera_coords
projected_vertices /= projected_vertices[2, :]
projected_vertices = projected_vertices[:2, :].T.astype(int)
depths = camera_coords[2, :]
mesh_color_buffer = np.zeros((buffer_height, buffer_width, 3), dtype=np.float32)
# Loop through each triangle to render it
for triangle in triangles:
# Get 2D points and depths for the triangle vertices
points_2d = np.array([projected_vertices[v] for v in triangle])
triangle_depths = [depths[v] for v in triangle]
colors = np.array([vertex_colors[v] for v in triangle])
# Sort the vertices by their y-coordinates for scanline rendering
order = np.argsort(points_2d[:, 1])
points_2d = points_2d[order]
triangle_depths = np.array(triangle_depths)[order]
colors = colors[order]
y_mid = points_2d[1, 1]
for y in range(points_2d[0, 1], points_2d[2, 1] + 1):
if y < 0 or y >= buffer_height:
continue
# Determine start and end x-coordinates for the current scanline
if y < y_mid:
x_start = interpolate_values(y, points_2d[0, 1], points_2d[1, 1], points_2d[0, 0], points_2d[1, 0])
x_end = interpolate_values(y, points_2d[0, 1], points_2d[2, 1], points_2d[0, 0], points_2d[2, 0])
else:
x_start = interpolate_values(y, points_2d[1, 1], points_2d[2, 1], points_2d[1, 0], points_2d[2, 0])
x_end = interpolate_values(y, points_2d[0, 1], points_2d[2, 1], points_2d[0, 0], points_2d[2, 0])
x_start, x_end = int(x_start), int(x_end)
# Loop through each pixel in the scanline
for x in range(x_start, x_end + 1):
if x < 0 or x >= buffer_width:
continue
# Compute barycentric coordinates for the pixel
s, t, u = compute_barycentric_coords(points_2d, x, y)
# Check if the pixel lies inside the triangle
if s >= 0 and t >= 0 and u >= 0:
# Interpolate depth and color for the pixel
depth_interp = s * triangle_depths[0] + t * triangle_depths[1] + u * triangle_depths[2]
color_interp = s * colors[0] + t * colors[1] + u * colors[2]
# Update the pixel if it is closer to the camera
if depth_interp < mesh_depth_buffer[y, x]:
mesh_depth_buffer[y, x] = depth_interp
mesh_color_buffer[y, x] = color_interp
# Convert float colors to uint8
mesh_color_buffer = (mesh_color_buffer * 255).astype(np.uint8)
return mesh_color_buffer, mesh_depth_buffer
转换后的网格的色彩和深度缓冲区与原始 RGB 图像及其估计的深度图一起输入到combine_frames()函数中。该函数旨在合并两组图像和深度帧。它使用深度信息来决定原始帧中哪些像素应该由渲染网格帧中的对应像素替换。具体来说,对于每个像素,函数检查渲染网格的深度值是否小于原始场景的深度值。如果是,认为该像素在渲染网格帧中更“接近”相机,并相应地替换色彩和深度帧中的像素值。函数返回合并后的色彩和深度帧,有效地根据深度信息将渲染网格叠加到原始场景中。
# Combine the original and mesh-rendered frames based on depth information
def combine_frames(original_frame, rendered_mesh_img, original_depth_frame, mesh_depth_buffer):
# Create a mask where the mesh is closer than the original depth
mesh_mask = mesh_depth_buffer < original_depth_frame
# Initialize combined frames
combined_frame = original_frame.copy()
combined_depth = original_depth_frame.copy()
# Update the combined frames with mesh information where the mask is True
combined_frame[mesh_mask] = rendered_mesh_img[mesh_mask]
combined_depth[mesh_mask] = mesh_depth_buffer[mesh_mask]
return combined_frame, combined_depth
这是第一个对象——大象的mesh_color_buffer、mesh_depth_buffer和combined_frame的视觉效果。由于大象对象在帧内没有被任何其他元素遮挡,因此完全可见。在不同的放置方式下,会发生遮挡。

(左) 大象网格的计算色缓冲区 | (右) 大象网格的计算深度缓冲区 | (底部) 组合帧
相应地,我将第二个网格——汽车,放置在路边的路缘。我还调整了它的初始方向,使其看起来像是被停放在那里。以下是该网格的mesh_color_buffer、mesh_depth_buffer和combined_frame的视觉效果。

(左) 小汽车网格的计算色缓冲区 | (右) 小汽车网格的计算深度缓冲区 | (底部) 组合帧
下面是插入了两个对象的点云可视化。由于新对象引入了新的遮挡区域,出现了更多的白色间隙。

插入对象后第一帧的生成点云
在计算了每个视频帧的叠加图像后,我们现在准备渲染我们的视频。
第 4 步:从处理过的帧渲染视频
在depth_aware_object_insertion.py的最后一部分,我们仅需使用render_video_from_frames函数从插入了物体的帧渲染视频。你也可以在此步骤调整输出视频的帧率。代码如下:
video_name = 'depth_aware_object_insertion_demo.mp4'
save_directory = "depth_aware_object_insertion_demo/"
frame_directory = "depth_aware_object_insertion_demo/"
image_extension = ".png"
fps = 15
# rendering a video of the overlayed frames
render_video_from_frames(frame_directory, image_extension, save_directory, video_name, fps)
这是我的演示视频:

(左) 由 Videvo 提供的素材,从 www.videvo.net 下载 | (右) 插入了两个物体的素材
此动画的高分辨率版本已上传至 YouTube。
总体而言,物体完整性似乎得到了很好的保持;例如,在场景中,汽车物体被街灯杆有效地遮挡。尽管在整个视频中汽车的位置有轻微的抖动——很可能是由于相机姿态估计的不完美——但世界锁定机制在演示视频中通常表现如预期。
虽然视频中的物体插入概念并不新颖,已有如 After Effects 这样的工具提供基于特征跟踪的方法,但这些传统方法对于不熟悉视频编辑工具的人来说通常非常具有挑战性和成本高昂。这时,基于 Python 的算法就显得很有前景。利用机器学习和基本的编程构造,这些算法有潜力使高级视频编辑任务变得更加平易近人,即使是对这一领域经验有限的个人也能接触到。因此,随着技术的不断发展,我预期基于软件的方法将作为强有力的推动者,平衡竞争格局,开辟视频编辑中的创意表达新途径。
祝你有美好的一天!
推导显示地理区域相对社会经济优势和劣势的评分
使用主成分分析(PCA)与实际数据
·
关注 发表在 Towards Data Science ·9 分钟阅读·2023 年 12 月 29 日
--
动机
存在公开获取的数据,描述了地理位置的社会经济特征。在我所在的澳大利亚,政府通过澳大利亚统计局(ABS)定期收集和发布有关收入、职业、教育、就业和住房的个人和家庭数据,按区域级别发布。一些发布的数据点示例如下:
-
收入相对较高/较低的人的百分比
-
在各自职业中被归类为管理者的人的百分比
-
没有正式教育水平的人的百分比
-
失业人数的百分比
-
具有 4 个或更多卧室的房产百分比
虽然这些数据点似乎重点关注个人,但它反映了人们对物质和社会资源的获取及其在特定地理区域内参与社会的能力,最终反映了该区域的社会经济优势和劣势。
给定这些数据点,有没有办法推导出一个评分,从最有利到最不利对地理区域进行排名?
问题
目标是推导一个评分,可以将其制定为回归问题,其中每个数据点或特征用于预测目标变量,在这种情况下是一个数值评分。这需要目标变量在某些实例中可用以训练预测模型。
然而,由于我们没有目标变量作为起点,我们可能需要用另一种方式来解决这个问题。例如,在假设每个地理区域在社会经济上有所不同的前提下,我们是否可以理解哪些数据点有助于解释最多的变异,从而基于这些数据点的数值组合推导出一个评分。
我们可以使用一种叫做主成分分析(PCA)的技术来实现这一点,本文演示了如何操作!
数据
ABS 在此网页的“数据下载”部分发布指示地理区域社会经济特征的数据点,数据位于“标准化变量比例数据立方体”[1]中。这些数据点以统计区域 1(SA1)级别发布,这是一个将澳大利亚划分为大约 200–800 人的区域的数字边界。这比邮政编码(Zipcode)或州的数字边界要更为详细。
为了在本文中进行演示,我将基于上述数据源表 1 中提供的 44 个数据点中的 14 个推导出社会经济评分(稍后我会解释为什么选择这个子集)。这些是:
-
INC_LOW: 生活在年家庭等效收入在 1 至 25,999 澳元之间的家庭中的人口百分比
-
INC_HIGH: 年家庭等效收入超过 91,000 澳元的人口百分比
-
UNEMPLOYED_IER: 15 岁及以上失业人口的百分比
-
HIGHBED: 占用私人物业中有四个或更多卧室的百分比
-
HIGHMORTGAGE: 支付每月按揭金额大于 2,800 澳元的占用私人物业的百分比
-
LOWRENT: 每周租金少于 250 澳元的占用私人物业的百分比
-
OWNING: 不带按揭的占用私人物业的百分比
-
MORTGAGE: 有按揭的占用私人物业的百分比
-
GROUP: 占用私人物业中属于群体占用的私人物业的百分比(例如公寓或单元)
-
LONE: 占用物业中仅有一个人居住的私人物业的百分比
-
OVERCROWD: 根据加拿大国家占用标准,需要一个或多个额外卧室的占用私人物业的百分比
-
NOCAR: 占用私人物业中没有汽车的百分比
-
ONEPARENT: 单亲家庭的百分比
-
UNINCORP: 至少有一个人是企业主的物业的百分比
步骤
在这一部分,我将逐步展示如何使用主成分分析(PCA)从 Python 代码中推导出澳大利亚 SA1 区域的社会经济评分。
我将从加载所需的 Python 包和数据开始。
## Load the required Python packages
### For dataframe operations
import numpy as np
import pandas as pd
### For PCA
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
### For Visualization
import matplotlib.pyplot as plt
import seaborn as sns
### For Validation
from scipy.stats import pearsonr
## Load data
file1 = 'data/standardised_variables_seifa_2021.xlsx'
### Reading from Table 1, from row 5 onwards, for column A to AT
data1 = pd.read_excel(file1, sheet_name = 'Table 1', header = 5,
usecols = 'A:AT')
## Remove rows with missing value (113 out of 60k rows)
data1_dropna = data1.dropna()
在执行 PCA 之前,一个重要的清理步骤是将每个 14 个数据点(特征)标准化到均值为 0 和标准差为 1。这主要是为了确保 PCA 分配给每个特征的载荷(可以将其视为特征重要性的指示器)在特征之间是可比的。否则,可能会对一个实际上并不重要的特征给予更多的重视或更高的载荷,反之亦然。
请注意,上述引用的 ABS 数据源已经标准化了特征。也就是说,对于未标准化的数据源:
## Standardise data for PCA
### Take all but the first column which is merely a location indicator
data_final = data1_dropna.iloc[:,1:]
### Perform standardisation of data
sc = StandardScaler()
sc.fit(data_final)
### Standardised data
data_final = sc.transform(data_final)
使用标准化数据,PCA 可以用几行代码完成:
## Perform PCA
pca = PCA()
pca.fit_transform(data_final)
PCA 旨在通过主成分(PC)来表示基础数据。PCA 中提供的主成分数量等于数据中标准化特征的数量。在这种情况下,返回了 14 个主成分。
每个主成分(PC)是所有标准化特征的线性组合,只是通过其各自的标准化特征的载荷进行区分。例如,下面的图像展示了分配给第一个和第二个主成分(PC1 和 PC2)的特征载荷。

图 1 — 返回前两个主成分的代码。图片由作者提供。
使用 14 个主成分,下面的代码提供了每个主成分解释了多少变异性的可视化:
## Create visualization for variations explained by each PC
exp_var_pca = pca.explained_variance_ratio_
plt.bar(range(1, len(exp_var_pca) + 1), exp_var_pca, alpha = 0.7,
label = '% of Variation Explained',color = 'darkseagreen')
plt.ylabel('Explained Variation')
plt.xlabel('Principal Component')
plt.legend(loc = 'best')
plt.show()
如下图所示,主成分 1(PC1)在原始数据集中占据了最大的变异比例,而每个后续的主成分解释的变异较少。具体来说,PC1 解释了数据中约 35%的变异。

图像 2 — 变异性由主成分(PC)解释。图片由作者提供。
为了演示本文,PC1 被选择为唯一的主成分来推导社会经济评分,原因如下:
-
PC1 在数据中相对地解释了足够大的变异性。
-
尽管选择更多主成分可能会(略微)解释更多的变异性,但在特定地理区域的社会经济优势和劣势背景下,这使得评分的解释变得困难。例如,如下图所示,PC1 和 PC2 可能提供关于特定特征(如‘INC_LOW’)如何影响地理区域社会经济变异的相互矛盾的叙述。
## Show and compare loadings for PC1 and PC2
### Using df_plot dataframe per Image 1
sns.heatmap(df_plot, annot = False, fmt = ".1f", cmap = 'summer')
plt.show()

图像 3 — PC1 和 PC2 的不同载荷。图片由作者提供。
要为每个 SA1 获得评分,我们只需将每个特征的标准化部分乘以其 PC1 载荷。可以通过以下方式实现:
## Obtain raw score based on PC1
### Perform sum product of standardised feature and PC1 loading
pca.fit_transform(data_final)
### Reverse the sign of the sum product above to make output more interpretable
pca_data_transformed = -1.0*pca.fit_transform(data_final)
### Convert to Pandas dataframe, and join raw score with SA1 column
pca1 = pd.DataFrame(pca_data_transformed[:,0], columns = ['Score_Raw'])
score_SA1 = pd.concat([data1_dropna['SA1_2021'].reset_index(drop = True), pca1]
, axis = 1)
### Inspect the raw score
score_SA1.head()

图像 4 — 按 SA1 划分的原始社会经济评分。图片由作者提供。
分数越高,SA1 在获取社会经济资源方面越有优势。
验证
我们怎么知道我们得出的评分甚至远远正确?
为了提供背景,ABS 实际发布了一个称为经济资源指数(IER)的社会经济评分,ABS 网站上定义为:
“经济资源指数(IER)专注于相对社会经济优势和劣势的财务方面,通过总结与收入和住房相关的变量。IER 排除了教育和职业变量,因为它们不是经济资源的直接衡量标准。它还排除了储蓄或股权等资产,虽然相关,但不能包括,因为这些资产在普查中未被收集。”
在不透露详细步骤的情况下,ABS 在其技术报告中表示,IER 的推导使用了与我们上面执行的相同的特征(14)和方法(PCA,仅 PC1)。也就是说,如果我们推导了正确的分数,它们应该可以与在此处发布的 IER 分数进行比较(“Statistical Area Level 1, Indexes, SEIFA 2021.xlsx”,表 4)。
由于发布的分数是标准化到均值 1,000 和标准差 100,我们首先通过将原始分数标准化到相同的水平来进行验证:
## Standardise raw scores
score_SA1['IER_recreated'] =
(score_SA1['Score_Raw']/score_SA1['Score_Raw'].std())*100 + 1000
为了进行比较,我们读取了按 SA1 发布的 IER 分数:
## Read in ABS published IER scores
## similarly to how we read in the standardised portion of the features
file2 = 'data/Statistical Area Level 1, Indexes, SEIFA 2021.xlsx'
data2 = pd.read_excel(file2, sheet_name = 'Table 4', header = 5,
usecols = 'A:C')
data2.rename(columns = {'2021 Statistical Area Level 1 (SA1)': 'SA1_2021', 'Score': 'IER_2021'}, inplace = True)
col_select = ['SA1_2021', 'IER_2021']
data2 = data2[col_select]
ABS_IER_dropna = data2.dropna().reset_index(drop = True)
验证 1— PC1 负荷
如下图所示,将上述得到的 PC1 负荷与ABS 发布的 PC1 负荷进行比较,表明它们之间相差-45%的常数。由于这只是一个缩放差异,因此不会影响标准化后的分数(均值为 1,000,标准差为 100)。

图像 5 — 比较 PC1 负荷。作者提供的图像。
(你应该能够用图像 1 中的 PC1 负荷来验证“Derived (A)”列)。
验证 2— 分数分布
以下代码创建了两个分数的直方图,其形状看起来几乎相同。
## Check distribution of scores
score_SA1.hist(column = 'IER_recreated', bins = 100, color = 'darkseagreen')
plt.title('Distribution of recreated IER scores')
ABS_IER_dropna.hist(column = 'IER_2021', bins = 100, color = 'lightskyblue')
plt.title('Distribution of ABS IER scores')
plt.show()

图像 6— IER 分布,重建与发布。作者提供的图像。
验证 3— SA1 的 IER 分数
作为最终验证,我们比较一下由 SA1 得出的 IER 分数:
## Join the two scores by SA1 for comparison
IER_join = pd.merge(ABS_IER_dropna, score_SA1, how = 'left', on = 'SA1_2021')
## Plot scores on x-y axis.
## If scores are identical, it should show a straight line.
plt.scatter('IER_recreated', 'IER_2021', data = IER_join, color = 'darkseagreen')
plt.title('Comparison of recreated and ABS IER scores')
plt.xlabel('Recreated IER score')
plt.ylabel('ABS IER score')
plt.show()
如下图所示的对角直线表明这两个分数基本相同。

图像 7— 按 SA1 比较分数。作者提供的图像。
此外,以下代码显示了两个分数之间的相关性接近 1:

图像 8— 重建分数与发布分数之间的相关性。作者提供的图像。
总结性思考
本文的演示有效复制了 ABS 如何校准 IER,这是其发布的四个社会经济指数之一,可用于排名地理区域的社会经济状况。
退一步看,本质上我们所取得的成就是将数据的维度从 14 减少到 1,丢失了一些数据传达的信息。
PCA 等降维技术也常用于将高维空间(如文本嵌入)减少到 2–3 个(可视化的)主成分。
参考资料
[1] 澳大利亚统计局(2021 年),地区社会经济指数(SEIFA),ABS 网站,访问日期:2023 年 12 月 29 日(知识共享许可)
致谢
我从这个 精算师学会代码库 中获取了一些 Python 代码,该库试图复制 ABS 发布的另一个指数(尽管我设法以更高的精度部分复制了 IER,如图 7 所示)。
在我乘风 AI/ML 浪潮之际,我喜欢以一种全面的语言编写和分享逐步指南和操作教程,并附带可运行的代码。如果你想访问我所有的文章(以及来自其他实践者/作者在 Medium 上的文章),你可以通过 这个链接 注册!
设计模式与 Python:构建器

图片由 Anton Maksimov 5642.su 提供,来自 Unsplash
学习如何使用构建器设计模式来提升你的代码
·发表于 Towards Data Science ·阅读时间 6 分钟·2023 年 10 月 12 日
--
介绍
对于从事 AI 开发的人来说,一个重要的技能是编写干净、可重用的代码。因此,今天我将使用Deepnote介绍另一种设计模式。
无论你在深度学习、统计学或其他领域的水平如何,如果你的代码不干净且不易于重用,你将永远无法开发出具有重大影响的东西。这就是为什么我认为数据科学家拥有软件工程技能非常重要的原因。 设计模式是所有编写代码的人都应该了解的东西。今天我们要讨论的是一个叫做构建器的模式。
什么是设计模式?
设计模式只是一种对重复问题的通用设计解决方案。与其一遍又一遍地解决同样的问题,不如想出一种每次遇到相同问题时都能使用的解决方案,而这些解决方案已经被找到!幸运的是,有人已经想到了让我们的生活更轻松的方法!😃
设计模式有多种类型。但主要有 3 种:
-
创建型:涉及创建对象的过程。
-
结构型:涉及类和对象的组成。
-
行为型:定义了类和对象如何交互。
将责任分配给他们自己。

设计模式(作者提供的图片)
构建器设计模式
构建器是名为创建设计模式的一个部分,因为它精确地简化了对象的创建过程。想象一下你有一个类,它需要大量的参数才能实例化,或者对于 Python 用户来说,一个类的__init__()方法期望接收大量的输入参数。
假设你有一个类来设计公园,也许是因为你正在为视频游戏创建环境。你可以以各种方式自定义公园,添加和移除各种元素。你可以添加游戏、孩子们,或者创建一个满是动物的公园等等。

图像作者提供
但是在实现层面,我们如何处理所有这些类型的公园呢?最直观的解决方案可能是创建一个基础的公园类,其他类然后扩展这个基础类以包含各种特性。但在这种情况下,我们会得到四个子类,在实际项目中,我们会有大量的子类,这会使我们的代码变得不切实际。
或者我们可以创建一个类,公园类,并使用一个巨大的构造函数,可以接收大量的输入参数。但问题是,在大多数情况下,输入参数将为空,因为我们并不总是想要创建一个什么都有的公园,而且代码看起来也会有点丑。
构建器采用的解决方案是将我们想要包含的各种特性创建在公园类中的不同方法中,称为构建器,而不是将所有内容都放在构造函数中。这样我们可以根据需要一步一步地构建公园的各个部分。

ParkBuilder(图像作者提供)
让我们开始编写代码吧!
现在让我们看看如何在 Python 中实现这个设计模式。在这个例子中,我们想要构建不同类型的机器人,这些机器人有不同的配置。以下代码将由 5 个基本部分组成。
- 机器人类:
- 首先,我们创建一个
Robot类,表示我们想要构建的对象,包括其所有属性,如head、arms、legs、torso和battery。
# Define the Robot class
class Robot:
def __init__(self):
self.head = None
self.arms = None
self.legs = None
self.torso = None
self.battery = None
2. 构建器接口:
RobotBuilder接口是一个抽象类,它定义了一组用于构建Robot对象不同部分的方法。这些方法包括reset、build_head、build_arms、build_legs、build_torso、build_battery和get_robo。
from abc import ABC, abstractmethod
class RobotBuilder(ABC):
@abstractmethod
def reset(self):
pass
@abstractmethod
def build_head(self):
pass
@abstractmethod
def build_arms(self):
pass
@abstractmethod
def build_legs(self):
pass
@abstractmethod
def build_torso(self):
pass
@abstractmethod
def build_battery(self):
pass
@abstractmethod
def get_robot(self):
pass
3. 具体构建器:
-
现在我们有实现了抽象类
RobotBuilder的构建器类,它们分别是HumanoidRobotBuilder和DroneRobotBuilder。这些构建器为机器人提供了不同的设置,使它们彼此区分开来。 -
记住,每个构建器保持一个正在构建的
Robot实例。
# Define a Concrete Builder for a Robot
class HumanoidRobotBuilder(RobotBuilder):
def __init__(self):
self.robot = Robot()
self.reset()
def reset(self):
self.robot = Robot()
def build_head(self):
self.robot.head = "Humanoid Head"
def build_arms(self):
self.robot.arms = "Humanoid Arms"
def build_legs(self):
self.robot.legs = "Humanoid Legs"
def build_torso(self):
self.robot.torso = "Humanoid Torso"
def build_battery(self):
self.robot.battery = "Humanoid Battery"
def get_robot(self):
return self.robot
# Define a Concrete Builder for a Robot
class DroneRobotBuilder(RobotBuilder):
def __init__(self):
self.robot = Robot()
self.reset()
def reset(self):
self.robot = Robot()
def build_head(self):
self.robot.head = "Drone Head"
def build_arms(self):
self.robot.arms = "No Arms"
def build_legs(self):
self.robot.legs = "No Legs"
def build_torso(self):
self.robot.torso = "Drone Torso"
def build_battery(self):
self.robot.battery = "Drone Battery"
def get_robot(self):
return self.robot
4. 机器人导演:
-
名为
RobotDirector的类负责使用其可用的特定构建器指导机器人的构建过程。 -
在这个类中,你会找到
set_builder方法来激活你需要的构建器,以及build_humanoid_robot和build_drone_robot方法来创建不同类型的机器人。 -
导演的方法返回最终构造的机器人对象。
# Define the RobotDirector class with methods to create different robots
class RobotDirector:
def __init__(self):
self.builder = None
def set_builder(self, builder):
self.builder = builder
def build_humanoid_robot(self):
self.builder.reset()
self.builder.build_head()
self.builder.build_arms()
self.builder.build_legs()
self.builder.build_torso()
self.builder.build_battery()
return self.builder.get_robot()
def build_drone_robot(self):
self.builder.reset()
self.builder.build_head()
self.builder.build_torso()
self.builder.build_battery()
return self.builder.get_robot()
5. 客户端代码:
-
让我们创建一个
RobotDirector实例。 -
然后创建一个
HumanoidRobotBuilder并将其设置为类人机器人的活动构建器。 -
我们还使用导演的
build_humanoid_robot方法来创建一个类人机器人。 -
现在我们可以创建一个
DroneRobotBuilder并将其设置为无人机机器人的活动构建器。 -
现在我们需要使用导演的
build_drone_robot方法来创建一个无人机机器人。 -
最后,我们打印出这两种机器人的组件。
# Client code
if __name__ == "__main__":
director = RobotDirector()
humanoid_builder = HumanoidRobotBuilder()
director.set_builder(humanoid_builder)
humanoid_robot = director.build_humanoid_robot()
drone_builder = DroneRobotBuilder()
director.set_builder(drone_builder)
drone_robot = director.build_drone_robot()
print("Humanoid Robot Components:")
print(f"Head: {humanoid_robot.head}")
print(f"Arms: {humanoid_robot.arms}")
print(f"Legs: {humanoid_robot.legs}")
print(f"Torso: {humanoid_robot.torso}")
print(f"Battery: {humanoid_robot.battery}")
print("\nDrone Robot Components:")
print(f"Head: {drone_robot.head}")
print(f"Arms: {drone_robot.arms}")
print(f"Legs: {drone_robot.legs}")
print(f"Torso: {drone_robot.torso}")
print(f"Battery: {drone_robot.battery}")
就这样!😊
最后的想法
建造者模式将复杂对象的构造与其表示解耦。 正如我们在这个例子中所见,RobotDirector 负责协调构造过程,但并不清楚创建不同类型机器人的具体步骤。 具体的构建器,我们在这里称之为 HumanoidRobotBuilder 和 DroneRobotBuilder,为构建特定机器人配置提供逐步实现。
这个模式允许灵活性和可扩展性,使得创建具有可变属性的复杂对象成为可能,同时保持客户端代码简洁易用。所有这些使我们能够以清晰且一致的方式构建复杂的对象。
如果你对这篇文章感兴趣,可以在 Medium 上关注我!😁
💼 Linkedin ️| 🐦 Twitter | 💻 网站
你可能对我之前的一些文章也感兴趣:
-
机器学习工程师的 Python 设计模式:观察者
-
机器学习工程师的 Python 设计模式:抽象工厂
《Python 机器学习工程师的设计模式:原型》

图片来源:Robert Katzki在Unsplash
学习如何使用原型设计模式来增强你的代码
·发表于 Towards Data Science ·6 min 阅读·2023 年 12 月 5 日
--
介绍
这不是我第一次写关于设计模式的博客文章。在我最近的文章中,我收到了关于这个话题的积极反馈,因为显然在 Python 世界中使用设计模式并不常见。我认为人们应该学习这些模式,以增强和改进他们的代码。此外,今天的 AI 软件严重依赖 Python,所以我认为这些教程对所有从事 AI 的人都有用。我将在Deepnote上运行我的代码:这是一个基于云的笔记本,非常适合协作数据科学项目。
什么是设计模式?
设计模式为那些在软件设计中经常出现的问题提供了明确的解决方案。这些模式提供可重用的解决方案,避免了一次次重复解决同一问题,加快了整个开发过程。
设计模式本质上提供了一个经过验证的、稳健的蓝图,以最佳方式解决特定问题,使我们的工作更轻松。
设计模式有多种类型,通常分为三大类:
-
创建型模式:这些模式关注于对象的创建,提供对象创建机制,同时保持系统的灵活性和高效性。
-
结构型模式:这些模式围绕类和对象的组成展开,处理不同组件之间的关系以形成更大的结构。
-
行为模式:这一类别管理类和对象如何交互,概述了它们之间的责任分配。它定义了在软件系统内进行通信和协作的协议。

设计模式(图片来源:作者)
问题
当我们在使用 Python 处理大型项目时,我们通常采用面向对象的编程方法来使代码更具可读性。通常,我们最终会有很多类和大量的对象。
有时发生的情况是我们想要创建一个对象的精确副本。你怎么做?以简单的方式,你可以实例化另一个相同类的对象,然后复制你想克隆的对象的每个内部字段。但这个过程很慢且乏味。
此外,还可能存在另一个问题。有时你不能轻易实例化一个对象,因为调用一个类的构造函数可能是昂贵的。例如,假设在构造函数中你运行一个需要支付费用的外部服务的 API 请求。
我们如何解决这个问题?嗯……通过设计模式,特别是使用一种创建型模式:原型模式。
原型设计模式
首先,我们需要创建一个包含 clone() 抽象方法的抽象类(或接口)。我们创建的所有类都必须实现此接口,并定义如何克隆该类本身的对象。这样,克隆的职责不在于类,而在于对象本身。

原型设计模式
我现在将使用 Python 的 ABC 库创建一个抽象类。
以下类将定义一个带有一些属性的车辆原型。
from abc import ABC, abstractmethod
import time
# Class Creation
class Prototype(ABC):
# Constructor:
def __init__(self):
# Mocking an expensive call
time.sleep(2)
# Base attributes
self.color = None
self.wheels = None
self.velocity = None
# Clone Method:
@abstractmethod
def clone(self):
pass
现在我们可以创建一些实现此接口的类,为此,它们必须实现 clone() 抽象方法。在 Python 中,为了创建副本,我们可以使用 deepcopy() 或 copy 库中的浅拷贝(shallow copy())方法。
具体来说,浅拷贝复制对非基本字段的引用,而深拷贝生成具有相同数据的新实例。
现在让我们定义一个具体的类。我将让构造函数休眠 2 秒钟,以模拟如介绍中所述的构造函数的昂贵调用。
import copy
import time
class RaceCar(Prototype):
def __init__(self, color, wheels, velocity, attack):
super().__init__()
# Mock expensive call
time.sleep(2)
self.color = color
self.wheels = wheels
self.velocity = velocity
# Subclass-specific Attribute
self.acceleration = True
# Overwriting Cloning Method:
def clone(self):
return copy.deepcopy(self)
每次我想实例化一个 RaceCar 对象时都需要 2 秒,因为有 sleep 方法。我们可以监控这个。
import time
start = time.time()
print('Starting to create a race car.')
race_car = RaceCar("red", 4, 40)
print('Finished creating a race car', )
end = time.time()
#will take 2 seconds
print('Time to complete: ' , end-start)
很好!我们创建了一辆赛车,这并不难。现在我想要制作 5 个副本,因为我的目的是开发一个有很多汽车的视频游戏。我们可以简单地在 for 循环中做同样的事。
cars = []
start = time.time()
print('Start instantiating clones', )
for i in range(5):
race_car = RaceCar("red", 4, 40)
cars.append(race_car)
end = time.time()
print('Time to complete: ', end-start)
如果你运行这段代码,它将花费 2s*5 即总共 10s! 😵💫
如果我们改用 clone 方法呢?我们来试试吧。
# now we create clones by using propotypes
cars = []
start = time.time()
print('Instantiating first car', )
race_car = RaceCar("red", 4, 40)
for i in range(5):
race_car = race_car.clone()
cars.appen(race_car)
end = time.time()
print('Time to complete: ', end-start)
这段代码只需 2 秒钟运行,这就是实例化第一辆车所需的时间。其他汽车的创建将不会花费时间。 太棒了!
机器学习示例
这个模型如何在机器学习脚本中使用?假设你实例化了一个预定义的 PyTorch 类模型,如下所示。
import copy
import torch
import torch.nn as nn
class NeuralNetwork(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super(NeuralNetwork, self).__init__()
self.layer1 = nn.Linear(input_size, hidden_size)
self.activation = nn.ReLU()
self.layer2 = nn.Linear(hidden_size, output_size)
def forward(self, x):
x = self.layer1(x)
x = self.activation(x)
x = self.layer2(x)
return x
def clone(self):
return copy.deepcopy(self)
一旦定义了类,你就可以创建你的模型对象。
# Base neural network configuration
base_model = NeuralNetwork(input_size=10, hidden_size=64, output_size=1)
现在,你可能想要对你的模型进行一些变化,也许你只对修改一两个参数感兴趣。你可以创建第一个模型的克隆,并手动更改这些参数。
# Clone the base model to create variations
model_variation_1 = base_model.clone()
model_variation_1.activation = nn.Tanh()
model_variation_2 = base_model.clone()
model_variation_2.hidden_size = 128
# Display summaries of the models
print("Base Model Summary:")
print(base_model)
print("\nModel Variation 1 Summary:")
print(model_variation_1)
print("\nModel Variation 2 Summary:")
print(model_variation_2)
完成了,很简单吧?😁
最后的想法
在这篇文章中,我解释了为什么你应该在 Python 中采用设计模式。设计模式不仅仅是你可以在少数编程语言中使用的东西(例如 Java),它是一种解决常见问题的理论方案。实例化许多具有相同属性的对象可能会很慢且昂贵,这就是为什么通过在接口中指定克隆方法,我们可以缓解这个问题并创建我们需要的任意数量的克隆。
作为一个提示,提升你作为 AI 工程师的技能的方式就是提升你作为软件工程师的技能!
如果你对这篇文章感兴趣,请在 Medium 上关注我!😁
💼 Linkedin ️| 🐦 Twitter | 💻 网站
你可能会对我过去关于设计模式的一些文章感兴趣:
-
机器学习工程师的 Python 设计模式:观察者
-
机器学习工程师的 Python 设计模式:抽象工厂
设计多方交互的对话代理
多一个人如何影响对话?
·
关注 发表在 Towards Data Science ·10 分钟阅读·2023 年 4 月 6 日
--
这是对在 IWSDS 2023上发表的一篇论文的缩写。 我 与赫瑞瓦特大学的 SPRING 团队共同撰写了这篇论文,详细信息如下。如果您想引用本文中讨论的内容,请引用标题为“面向社交机器人多方任务对话的数据收集s”的论文:
**Harvard**:
Addlesee, A., Sieińska, W., Gunson, N., Garcia, D.H., Dondrup, C., Lemon, O., 2023\. Data Collection for Multi-party Task-based Dialogue in Social Robotics. Proceedings of the 13th International Workshop on Spoken Dialogue Systems Technology (IWSDS).**BibTeX**:
@inproceedings{addlesee2023data,
title={Data Collection for Multi-party Task-based Dialogue in Social Robotics},
author={Addlesee, Angus and Siei{\'n}ska, Weronika and Gunson, Nancie and Garcia, Daniel Hern{\'a}ndez and Dondrup, Christian and Lemon, Oliver},
journal={Proceedings of the 13th International Workshop on Spoken Dialogue Systems Technology (IWSDS)},
year={2023}
}
考虑与对话代理的互动,也许是你手机上的 Siri、家中的 Amazon Alexa,或者网站上的虚拟客户服务代理。这些互动是‘双方面的’,即它们只涉及一个人(你自己)和一个代理。这对于所有语音助手和基于聊天的代理来说都是典型的。
对话代理被设计用于一对一的互动。


这两张照片展示了双方(对话式)互动。一张是有一个电话语音助手而不是一个人(左 source),另一张是(右 source)。
我们是社交动物,人们自然能够处理与不止一个其他人的对话。考虑与几个朋友一起喝咖啡、家庭晚餐对话,甚至是全体团队的工作会议。这些被称为‘多方’对话,而如今的对话代理并不为这种互动类型设计。
对话代理是否需要在多方设置中工作?多方对话是否带来了新的挑战?我们需要采取什么下一步措施来推进?我将在本文中回答这三个问题。


多方互动。考虑这些对话与上述的双方对话有什么不同(左 source),(右 source)。
多方设置中的对话代理
如今的系统被设计用于处理双方互动,当然这是因为通常情况下我们就是这样与它们互动的。Siri 不会被动地听你与朋友的对话并在需要时插话——它会在激活时听取你的单一请求。Google Assistant 和 Alexa 也非常相似,监听它们的唤醒词和一个发言。可以说,这些语音助手在家庭环境中可能会从多方理解中受益,但这并不紧迫。
对话代理正在被嵌入到虚拟代理和公共场所如博物馆、机场、购物中心和医院的社交机器人中等……人们与家人、朋友和看护者一起去这些地方——所以这些代理必须能够处理多方互动。
我将在本文中使用许多示例来说明观点,因此最好设定一下背景。让我们想象在医院记忆诊所候诊室中的一个机器人助手。这个机器人叫做 ARI,患者带着伴侣来进行预约。这个配对可能需要指引、咖啡、医院信息或只是一些娱乐。这正好是 EU SPRING 项目的背景,所有示例都将符合这个背景。

多方设置中的 ARI 机器人。版权 PAL Robotics
多方对话真的如此不同吗?
考虑到上述医院环境,我们的互动中只有一个额外的人。所以,机器人不仅要与患者互动,还必须同时与患者和陪伴者互动。这会改变对话吗?剧透:会的,变化很大!
说话人识别
在两人对话中,代理不需要识别说话者。这是微不足道的,因为说话者是对话中唯一的其他人。Alexa 有一个很棒的功能,只有在它识别到我的声音时才允许我购买物品——但这不是我所说的说话人识别。这是否标记为说话者 1、说话者 2 还是说话者 xyz 都无关紧要——对话代理的回应将是一样的。
然而,识别说话者对于理解多方对话至关重要。让我们假设患者和陪伴者想与机器人玩一个测验。机器人问“德国的首都是什么?”然后是以下互动:
1) I think it is Berlin.
2) Or Munich.
3) Yes, Munich.
没有说话者识别,我们无法确定这两个人是否达成了一致。有多个选项,让我们看看其中两个(P = 患者,C = 陪伴者):
1) P: I think it is Berlin.
2) P: Or Munich.
3) C: Yes, Munich.
在这个 PPC 案例中,患者和陪伴者已经达成一致,答案是慕尼黑。然后机器人可以告诉他们答案是错误的,告知他们正确答案,并继续下一个问题。或者:
1) P: I think it is Berlin.
2) C: Or Munich.
3) C: Yes, Munich.
在这个 PCC 案例中,患者提出了正确答案,而陪伴者则建议了第二个错误选项。陪伴者随后再次确认他们的确定性,但重要的是,患者没有同意。如果机器人在这种情况下将慕尼黑作为最终答案,患者会非常沮丧,因为他们提出了正确答案却被忽视了。
希望这个例子很清楚。在 PPC(或 PCP)案例中,一致意见已经达成,继续测验是正确的行动。在 PCC 案例中,机器人应该保持沉默,等待患者的回应。
对话代理只有在识别到说话者时才能知道哪个行动是正确的。这在两人对话中并不成立。
收件人识别
与说话人识别类似,弄清楚谁在被说话者讲述在两人对话中也很简单。说话者显然是在对第二个人/代理讲话。然而,这在多方对话中并非如此。说话者可能在对一个个体、另一个个体或两者同时讲话。为了说明这一点,请考虑(其中 R = 机器人,P = 患者):
1) P: What is my appointment about?
2) R: For your privacy, I don't know that, sorry.
3) P: What's my appointment about?
4) R: For your privacy, I don't know that, sorry.
5) P: Stop!
在这个例子中,病人在第 1 回合时最初向机器人提问。机器人随后作出了正确的回应。然而,在第 3 回合时,病人转向他们的伴侣并重复了相同的问题。由于机器人没有地址识别能力,它再次做出了相同的回应,这让病人感到沮丧。

在赫瑞瓦特大学测试的用于 SPRING 的 ARI 机器人
回应选择或生成
决定虚拟代理应该如何回应用户是很困难的,这对二人对话和多方对话都是如此(不同于最后两个任务)。在多方环境中尤为具有挑战性,因为你的代理必须决定向谁发言。例如,机器人的回应会因其面对的是个人还是所有人而有所不同。我将再次说明这一点:
1) P: I would like a coffee.
2) C: and I desperately need the toilet.
3) R: ???
机器人可能会决定首先向病人发言,因为他们在伴侣之前请求了帮助。然而,由于紧急情况,机器人也可能决定优先考虑伴侣。接下来机器人说什么取决于它决定向谁发言。
上述三个任务(说话人识别、收件人识别和回应选择/生成)在文献中统称为“谁对谁说了什么?”。这是当前研究的重点。在我们的论文中,我们强调了多方代理的另外两个重要任务。
对话状态跟踪
在对话的上下文中理解每个用户发言的要点是很重要的。对话状态跟踪(DST)正是要做到这一点,并面临如 DSTC 和 MultiWoZ 这样的挑战。许多研究机构和公司为此任务分配了资源,但所有的数据集都是二人对话的。再次强调,DST 在多方环境中有所不同。
当前的 DST 模型可以输出用户请求特定信息、确认某事、提供信息等……但它们不能检测到同意或确定请求是否已被满足,因为这在二人对话中不会发生。例如:
1) P: Where is the lift
2) P: It is to the left of the reception
这种情况在二人对话中永远不会发生。一个人问一个问题然后立刻自己回答是不合逻辑的。然而,发言 2 可能由伴侣说出,机器人必须跟踪伴侣向病人提供了信息。
目标跟踪
最后,在多方环境中跟踪人们的目标也更加困难。类似于 DST 的差异,人们可以回答彼此的目标,而这种情况在二人对话中不会发生。代理必须能够确定用户目标是否被准确满足,以免重复刚刚回答者所说的内容。然而,如果回答者的回答是不正确的,机器人仍然需要回应,因为目标尚未完成。
另一个主要的目标跟踪差异是我们人类非常擅长的——确定人们是否有共同的目标。如果两个人进咖啡馆点咖啡,咖啡师的互动方式会根据这是否是两个人分别点咖啡还是两个人一起点咖啡而有所不同。

两个人点咖啡 (来源)
在上图中,这两个人可能是分开点餐或一起点餐。在后一种情况下,咖啡师可能会说“你们一起付款吗?”,但如果这两个人互不相识,这会显得很奇怪。
人们可以非常明确地表明共同目标(例如,“我们想要……”,“我儿子需要……”,或“我也是”)。但我们假设当人们接续彼此的句子(分裂话语)时,目标是共享的。例如:
1) P: Whaere is the cafe?
2) C: because we are very hungry.
从上述两个话语中,我们可以推测这两个人有共同的目标。这在双人互动中不会发生。
我希望我已经说服了你们,多方互动与双人互动有很大的不同,它们包含了许多额外的挑战,如果我们要在公共空间中拥有自然互动的代理,必须解决这些问题。
我们如何进展?
由于该领域的大多数研究集中在双人互动上,适合的数据非常有限,且没有具有 DST 或目标跟踪注释的数据。为了在多方环境中训练系统执行上述任务,我们必须收集数据。我们——SPRING 项目——正在医院记忆诊所中使用 ARI 机器人收集多方对话。

我在论文中展示了我们在洛杉矶IWSDS 2023会议上使用的 ARI 机器人用于数据收集的情况。
访问医院记忆诊所的患者及其陪同者会被提供带有不同目标的角色扮演场景。为了收集具有上述各种挑战的对话,我们设计了六种条件。我写这篇文章时,第三轮数据收集正在进行中!
我在论文中提供了更多细节,但六种条件如下:
有帮助的陪同者
患者有一个目标,但陪同者只是被告知要协助患者。他们必须与机器人互动以完成这些目标(例如,获取咖啡,了解咖啡馆何时关门等)。
这个条件是我们期望的典型互动方式。患者可能需要某些东西,但陪同者自己没有目标。
共享目标
如上节的目标跟踪部分所讨论的,有时人们有共同的目标。在这种情况下,病人和陪同者会被分配相同的目标,例如,他们都可能想吃午餐。这与“有帮助的陪同者”条件仅有些微不同,但初步观察表明在这种条件下会出现更多的分裂性话语。
不情愿的病人
访问医院的人可能会因为过于害羞(或甚至过于担忧)而不直接与机器人对话。在这种情况下,病人有一个目标,但他们不会直接与机器人交谈。因此,陪同者必须作为病人和机器人之间的中介,以完成病人的目标。
不同的目标
人们并不总是有相同的目标。例如,病人可能想要一杯咖啡,而陪同者则需要使用洗手间。在这种情况下,病人和陪同者会被分配不同的目标。
缺失信息
如前所述,机器人由于隐私原因不能总是回答问题。例如,机器人不能从伦理上透露病人为什么来医院。此外,机器人不能对病人进行面部识别以确认身份。在这种情况下,陪同者会得到一些机器人无法知道的缺失信息。陪同者必须向机器人提供这些信息,以实现病人的目标。由于这很难解释,这里举个例子:
1) P: Where is my appointment?
2) R: Sorry, I don't have access to that information.
3) C: It's with Dr Smith
4) R: Dr Smith is in room 17.
如你所见,病人的目标是找到他们的预约地点。陪同者被提供了有关预约医生的额外信息。
不一致:
最后,多方互动可能涉及分歧。如果机器人提供了咖啡机的位置,陪同者可能会不同意。在这种情况下,病人会有一个目标,而陪同者则拥有与机器人信息相矛盾的额外信息。机器人应该能够识别冲突,重新提供正确的信息,并向参与者保证信息是正确的。
你可以在这里找到带有引文的完整论文,你可以通过Medium、Twitter或LinkedIn联系我。
设计运筹学解决方案:一个用户友好的 Streamlit 路由应用
从数学模型到 Python 软件工程
·
关注 发表在 Towards Data Science · 12 分钟阅读 · 2023 年 9 月 30 日
--
照片由 Caspar Camille Rubin 提供,来源于 Unsplash
在运筹学和数据科学中,弥合理论与应用之间的差距至关重要。虽然理论基础形成了优化解决方案的核心,因为它们提供了解决复杂问题的方法,但我们也应关注如何使这些概念变得可访问并且可用于实际应用。
旅行商问题(TSP)无疑是组合优化中研究最广泛的问题(Rego et al., 2011)。它的描述很简单(至少口头上),并且可以用来展示一些现代路由 API 的可能组件。因此,我实在无法想到一个更好的替代方案来在这个故事中使用。
在本教程中,你将学习如何使用 Python 库Streamlit构建一个基于用户提供输入数据来解决 TSP 的 Web 应用程序。由于我们关注实际应用,因此解决方案不仅限于欧几里得距离。它应该能够使用坐标提取实际的道路行驶距离,并将这些距离纳入优化过程。为此,将使用 OpenStreetMap API。
如果你对深入了解数值优化的理论方面感兴趣,你可能会想查看我关于线性规划和车辆路径问题(这是 TSP 的一个推广)的文章。
你准备好动手了吗?来看看我们的最终结果吧……

最终应用程序的屏幕截图。(动画由作者制作)。
由于整个代码可能过长,未能在此故事中全部包含,部分代码已引用但省略。不过,你可以在这个库中查看完整代码。
旅行商问题
TSP 的目标是找到连接N个点的最短路径。随着考虑的点数增加,由于问题的组合性质,找到一个最佳或近似最佳的解决方案可能变得非常具有挑战性。

使用自定义启发式算法获得的 1000 个地点的 TSP 优质解决方案。(图片由作者提供)。
从精确方法到启发式和元启发式,解决 TSP 的技术必须考虑元素之间的成对距离以获得解决方案。虽然在这篇文章中,我不会深入讨论解决方法的细节,但可以考虑一个通用函数来解决 TSP,其签名如下:
solve_tsp(distances: np.ndarray) -> List[int]
你可以在原始代码库中找到一些解决方案策略的实现方法。然而,在这里,我们将重点讨论如何将类似于solve_tsp的组件视为软件开发中的一个组成部分。
库文件夹结构
在本教程中,将采用一个简单的扁平文件夹结构,其中包含以下元素:
-
.streamlit: 这个文件夹是存放一般 Streamlit 设置的地方。
-
data: 存储在开发过程中使用的数据集的文件夹,可能包含常规示例。
-
assets: 这个文件夹存储在主应用脚本中引用的元素,如图像、徽标等。
-
optimizer: 应用程序中使用的内部 Python 包。
-
app.py: 主要的 Streamlit 脚本。
-
Dockerfile: 构建应用程序 Docker 镜像的说明。
-
requirements.txt: 运行应用程序所需的 Python 依赖项。
-
README.md: 项目描述。
-
.dockerignore 和 .gitignore: 顾名思义,分别是 Docker 构建和 Git 忽略的文件。
tsp-app/
│
├── .streamlit/ # General streamlit configuration
│ └── config.toml
│
├── data/ # Directory for data if any
│ ├── dataset.csv
│ └── ...
│
├── assets/ # These will be referenced in the app
│ ├── dataset.csv
│ └── ...
│
├── optimizer/ # TSP optimizer internal package
│ ├── __init__.py
│ └── ...
│
├── app.py # Main Streamlit script
│
├── Dockerfile # Docker configuration file
├── .dockerignore
│
├── .gitignore # File ignored in Git
├── requirements.txt # Python dependencies
└── README.md # Project description
更复杂的应用程序可能会为 Streamlit 界面的实用函数创建一个单独的包。
...
├── optimizer/ # TSP optimizer internal package
│ ├── __init__.py
│ └── ...
│
├── interface/ # Utility functions for the interface
│ ├── __init__.py
│ └── ...
│
├── app.py # Main Streamlit script
...
只需小心并确保在项目中定义路径时,以从运行脚本或启动应用程序的目录的相对位置为准。
首先,确保你在 Python 环境中安装了所有的依赖项。
pip install -r requirements.txt
在 TSP 应用程序的情况下,我们可能会遇到一些依赖冲突,因此建议你单独使用 --no-deps 选项安装 ortools。
pip install --no-deps ortools==9.7.2996
使用所提议的扁平结构,你可以通过运行以下命令行从目录的根目录运行应用程序:
streamlit run app.py
或者,你可以使用以下命令,利用我的代码库中提供的 Dockerfile 来构建一个 docker 镜像。
docker build -t tsp_app:latest .
你可以将 tsp_app 替换为你想要的任何仓库名称,将 latest 替换为任何版本。
然后你可以通过运行以下命令来执行你的应用程序
docker run -p 8501:8501 tsp_app:latest
让我们更好地理解如何使用 app.py 文件。
一般结构
app.py 脚本的一般结构可以通过以下伪代码总结:
Imports
Definition of utility functions (could have been in a separate file)
Declaring constants
Declaring default session_state attributes
Parsing solver parameters
Parsing input file
if input is not None:
read (and cache) input data
if execute button is pressed:
solve model based on input
store solution as a session_state attribute
if solution is not None:
make output available
plot results
尽管看起来很简单,但这个伪代码的一些点是关键的:
当按下一个 button 时,Streamlit 运行与之相关的代码并重置其状态。因此,请遵循 Streamlit 指南,并避免将这些元素保留在按钮的条件内部:
-
显示的项目应在用户继续操作时保持不变。
-
其他会导致脚本重新运行的控件。
-
不修改会话状态或写入文件/数据库的进程。
这就是为什么我们将尽可能多地探索 session_state 属性。优化完成后,即使我们按下下载按钮或更改一些配置而不按“执行”按钮,我们也希望继续探索地图。因此,显示解决方案的条件仅限于其在 session_state 中的存在,而不是“执行”按钮。
此外,一些操作可能会很昂贵,缓存其结果以避免浪费时间进行重新运行是有用的。记住:Streamlit 每次用户交互或代码更改时都会从上到下运行你的脚本。
基本配置
如前所述,我们可以在.streamlit文件夹中的config.toml文件中定义一般的 Streamlit 设置。在 TSP 示例中,我使用它来定义颜色和字体。这是一种浅紫色布局,但你可以尝试不同的颜色。只需确保在Hex 颜色代码中定义它们。
[theme]
primaryColor = "#5300A5"
backgroundColor = "#403E43"
secondaryBackgroundColor = "#1C1B1E"
textColor = "#F5F3F7"
font = "sans serif"
base = "dark"
让我们开始用 Python 导入填充app.py的内容。将使用os包来管理我们操作系统中的文件路径;BytesIO将用于模拟一个保存在内存中的可下载输出文件,而不是磁盘;json将用于序列化我们的输出解决方案;List只是一个类型提示。
处理数据框时,将使用pandas;求解器Highs从pyomo导入以解决我们的任务(如果是 MIP);streamlit是界面的基础;streamlit_folium将用于将folium地图插入到应用程序中。还导入了在内部包中定义的附加自定义函数。
import os
from io import BytesIO
import json
from typing import List
import pandas as pd
from pyomo.contrib.appsi.solvers.highs import Highs
import streamlit as st
from streamlit_folium import st_folium
from optimize.tsp import get_distance_xy, build_mip, solve_mip, TSP, plot_tour, request_matrix,\
plot_map, get_coord_path
session_state属性将存储当前解决方案的tour、给定输入文件的pandas数据框、该解决方案的值以及路线坐标路径(从 OpenStreetMap 获取)。
# Create current solution as session_state
if "tour" not in st.session_state:
st.session_state.tour = None
if "dataframe" not in st.session_state:
st.session_state.dataframe = None
if "sol" not in st.session_state:
st.session_state.sol = None
if "route_path" not in st.session_state:
st.session_state.route_path = None
使用的一些实用函数包括:
-
driving_distances: 从 OpenStreetMap 获取距离矩阵,提供一个数据框(在此处缓存结果非常重要)。
-
upload_callback: 在加载新输入文件时重置
session_state属性。 -
update_path: 给定一个旅行路线,获取相应的驾驶路径的地图坐标以绘制结果。
# Finds distance matrix
@st.cache
def driving_distances(dataframe: pd.DataFrame):
return request_matrix(dataframe)["distances"] / 1000
# Callback uploading a new file
def upload_callback():
st.session_state.tour = None
st.session_state.sol = None
st.session_state.route_path = None
# Update route path after solution
def update_path(tour: List[int], dataframe: pd.DataFrame):
coord_rt = dataframe.loc[tour, :]
path = get_coord_path(coord_rt)
st.session_state.route_path = path
然后让我们配置页面布局。在以下脚本中,我们为网页包含一个图标和一个标题;然后我们在侧边栏中包含相同的图标,并用 Markdown 样式写一个介绍。我建议你运行streamlit run app.py并检查到目前为止的结果。
# Path to icon
icon_path = os.path.join("assets", "icon_tsp.png")
# Set the page config to wide mode
st.set_page_config(
page_title="TSP",
page_icon=icon_path,
layout="wide",
)
st.sidebar.image(icon_path)
st.title("TSP")
st.write("Welcome to the Traveling Salesman Problem solver.")
我定义的一个实用函数读取README.md文件中的内容,并在用户选择此选项时显示这些内容。由于我希望将显示内容的条件保持独立于可能的重新运行,因此我使用了selectbox而不是button来实现。
display_tutorial = st.checkbox("Display tutorial")
if display_tutorial:
section = st.selectbox("Choose a section", ["Execution", "Solutions", "Contact"], index=1)
tutorial = read_section(section)
st.markdown(tutorial)
然后让我们定义求解器参数并上传一个输入文件……
输入数据和求解器参数
在这个示例中,我包括了两种输入类型的选项:
-
‘xy’: 使用欧几里得距离,输入数据必须具有‘x’和‘y’列。
-
‘lat-long’: 使用道路驾驶距离,输入数据必须具有‘lat’和‘long’列。
我还包括了两个求解器选项:
-
‘MIP’: 使用pyomo创建的模型,并用 HiGHS 求解。
-
‘Heuristic’: 使用 Google OR-Tools 路由算法,基于构造性 + 局部搜索启发式与多次启动。
这些参数以及求解时间限制将通过以下代码放置在应用程序侧边栏中:
problem_type = st.sidebar.selectbox("Choose an input type:", ["xy", "lat-long"], index=0)
method = st.sidebar.selectbox("Choose a strategy:", ["MIP", "Heuristic"], index=0)
time_limit = st.sidebar.number_input("Time limit", min_value=0, value=5, step=1)
为了上传文件,将使用file_uploader函数。注意,我们用于重置session_state属性的函数会在输入更改时被调用。
file = st.file_uploader("Upload input file", type=["csv"], on_change=upload_callback)
如果输入文件不为空(None),我们应该读取它并准备距离矩阵以待优化…
if file is not None:
dataframe = pd.read_csv(file)
st.session_state.dataframe = dataframe
distances = FORMATSproblem_type
start_button = st.button("Optimize")
注意,之前FORMATS被定义为一个字典,字典中的函数返回一个给定数据框和问题类型的距离矩阵。这些函数在内部模块中定义并导入到应用程序中。
FORMATS = {
"xy": get_distance_xy,
"lat-long": driving_distances
}
执行
现在执行依赖于点击“执行”按钮。这是一个每次点击一次的过程。根据这个条件,优化器将被执行,结果将存储在session_state属性中。
# Run if start is pressed
if file is not None and start_button:
# Solve MIP
if method == "MIP":
solver = Highs()
solver.highs_options = {"time_limit": time_limit}
model = build_mip(distances)
tour = solve_mip(model, solver)
st.session_state.tour = tour
# Solve Heuristic
elif method == "Heuristic":
model = TSP(distances)
tour = model.solve(time_limit=time_limit)
st.session_state.tour = tour
# Display the results
sol = model.obj()
st.session_state.sol = sol
# Update path in case of lat-long
if problem_type == "lat-long":
update_path(tour, dataframe)
你可以在这些函数调用中包含额外的小部件。例如,一个spinner或progress bar可能在执行过程中看起来不错。
然后,在按钮的执行条件之外,我们可以根据其存在性来提供我们的输出。
# Compute current state variables
sol = st.session_state.sol
tour = st.session_state.tour
dataframe = st.session_state.dataframe
if sol is not None and tour is not None:
col_left, col_right = st.columns(2) # Divide space into two columns
col_left.write(f"Current solution: {sol:.3f}")
col_right.download_button(
label="Download Output",
data=json.dumps(tour),
file_name='output.json',
mime='json',
)
在这个简单的例子中,我们提供了一个包含旅游过程中访问点序列的json文件。如果我们需要生成更复杂的输出,如 Excel 文件,BytesIO可能是一个有趣的替代方案。假设你想创建一个下载按钮,使得一个* pandas* 数据框字典可用。你可以使用如下内容:
buffer = BytesIO()
with pd.ExcelWriter(buffer) as writer:
for name, df in dataframes.items():
df.to_excel(writer, sheet_name=name)
st.download_button(
label="Download Output",
data=buffer,
file_name='output.xlsx',
mime='xlsx',
)
使用 OpenStreetMap 的详细路线
在应用程序的早期,我们使用了在内部包中定义的request_matrix函数,通过 OpenStreetMap API 和Python库requests来获取问题的距离矩阵。在这个函数中,后台执行的请求类似于:
f"https://router.project-osrm.org/table/v1/driving/{points}?sources={sources_str}&destinations={dest_str}&annotations={annotations}"
其中:
-
points:一个字符串,连接longitude和latitude的对,用“,”分隔同一对的坐标,用“;”分隔不同的对。
-
sources:一个整数列表,对应于来源(from)节点。
-
destinations:类似于 sources,但包含目的地(to)节点。
-
annotations:‘distance’,‘duration’或‘duration,distance’。
现在我们想获取解决方案路线的详细坐标以可视化结果。为此,我们将使用不同的请求。考虑两个函数,它们接收作为输入的已排序的旅游元素数据框。通过 API 获取的输出,我们将创建一个包含latitude、longitude的元组列表,并将其传递给folium以创建地图。
def get_request_points(coordinates: pd.DataFrame) -> List[str]:
return coordinates.apply(lambda x: f"{x['long']},{x['lat']}", axis=1).to_list()
def get_coord_path(coordinates: pd.DataFrame) -> List[Tuple[float, float]]:
pts = get_request_points(coordinates)
pts_req = ";".join(pts)
r = session.get(
f"http://router.project-osrm.org/route/v1/car/{pts_req}?overview=false&steps=true"
)
result = r.json()
first_route = result["routes"][0]
coords = [(p["location"][1], p["location"][0])
for l in first_route["legs"]
for s in l["steps"]
for p in s["intersections"]]
return coords
使用 Folium 绘制结果
现在考虑我们有通过函数get_coord_path获得的元组列表。我们现在必须使用它来创建我们的folium地图。
def plot_map(
path: List[Tuple[float, float]],
color: Union[str, tuple] = "darkblue",
zoom_start=8,
**kwargs
):
# Coordinates from path
lat, long = zip(*path)
# Create map
m = folium.Map(zoom_start=zoom_start)
new_line = folium.PolyLine(
path, weight=4, opacity=1.0,
color=color, tooltip=f"Route",
**kwargs
)
new_line.add_to(m)
# Trim zoom
sw = [min(lat), min(long)]
ne = [max(lat), max(long)]
m.fit_bounds([sw, ne])
return m
最后,我们可以将这个地图与streamlit_folium库结合起来,创建一个令人惊叹的结果可视化。
if tour is not None and dataframe is not None:
# Plot solution
if problem_type == "xy":
pass # Not really in the app, but skipping here
elif problem_type == "lat-long":
map = plot_map(st.session_state.route_path)
st_folium(map, width=700, height=500, returned_objects=[])
使用我在代码仓库中提供的输入文件,你可以找到并可视化经过所有美国大陆州首府的最短驾驶路线。

在所有大陆美国州首府之间的最短驾驶路线。(作者提供的图像)。
部署
到目前为止,我们的解决方案在本地执行时运行顺利。然而,你可能希望将应用程序分享给更广泛的受众。这时部署变得至关重要。你可以访问已部署的 TSP 应用程序 这里。
对于这个项目,我选择使用 Google Cloud Run 部署应用程序,因为我的想法是部署一个容器化应用程序(使用 Docker),并且我对该环境比较熟悉。类似的简洁而有用的教程可以在这个 其他故事 中找到。Streamlit 还提供了一个关于这个主题的快速指南 “如何使用 Docker 部署 Streamlit”。
对于 TSP 应用程序,我在 Dockerfile 中解决了 ortools 和 streamlit 之间的依赖冲突。其他部署方法严重依赖 requirements.txt 文件,这可能使得这种特定情况变得更加困难。然而,值得检查这些“3 种简单的方法来在线部署你的 Streamlit Web 应用”。
祝你在部署你自己的运筹学解决方案的旅程中一切顺利!
进一步阅读
与我之前的 Medium 故事不同,这篇文章强调了优化和运筹学作为软件开发中的组成部分,探讨了它们如何与其他工具集成,以创建简单而有影响力的应用程序。对于那些渴望深入了解数值优化机制的人,我推荐探索我关于它的综合列表,你可以在其中找到几个经典问题、建模策略、求解器的使用和理论方面的内容。

优化时代的故事
查看列表15 个故事


结论
在这个故事中,探索了优化与软件开发的结合,使用了简单而有效的网页开发框架Streamlit和开源路由 API OpenStreetMap。通过将运筹学的核心概念与网页应用的易用性相结合,可以创建出引导决策过程的惊人工具。
参考文献
Rego, C., Gamboa, D., Glover, F., & Osterman, C., 2011. 旅行商问题启发式算法:主要方法、实现和最新进展。欧洲运筹学杂志,211(3),427–441。
解密 Curvelets
信号处理深入分析
了解什么是 Curvelets,它们是如何构建的,以及它们可以用于什么
·
关注 发布于 Towards Data Science · 13 分钟阅读 · 2023 年 3 月 22 日
--
尼迈耶大厦,贝洛奥里藏特。照片由 Matheus Frade 提供,来自 Unsplash。
介绍
Curvelets 是图像和多维信号的多尺度、有方向、非自适应表示。如果这些词中的一些你不太理解,你来对地方了。
曲波变换是在 2000 年代初期由 Emmanuel Candès 和 David Donoho [1] 开发的,旨在解决之前替代方法存在的一些问题。小波变换通过使用任意的——但特别设计的——函数来推广一维傅里叶变换,而不是复杂的指数函数。像傅里叶变换一样,它们可以通过简单地在多个轴上重复应用变换来扩展到二维。用这种幼稚的方法构造二维小波时,在表示不完全水平或不完全垂直的边缘时会遇到问题。实际上,处理边缘有困难的变换可能会在强压缩图像中造成“块状”伪影。许多变换都遭遇了相同的问题,包括离散余弦变换(DCT),它是广泛使用的 JPEG 格式的核心(见图 1)。依赖小波变换改进 JPEG 的 JPEG2000,仍然遭遇相同的块状伪影问题。

图 1. "块状"伪影出现在 50 倍和 100 倍重采样的强压缩 JPEG 图像中。致谢:Shlomi Tal (CC BY-SA 3.0)。
曲波变换没有这些问题 —— 它们能够保持任何类型的边缘。它们还展现出一些良好的特性,例如是波动现象的最佳表示、能量守恒、单位性(其伴随/转置与其逆相同)等。
如今,曲波变换在各种图像处理任务中得到了应用,包括去噪、压缩、修补、平滑等。在地球物理学界,它已经成为处理若干任务(包括自适应减法、图像匹配、预处理等)的强大工具。在医学界,它也被用于分割、诊断等。
尽管深度学习在许多任务中迅速取代了经典算法,但曲波变换(以及其他小波变换)仍然有其用途。深度学习提供了创建自适应、多尺度信号表示的可能性,而曲波变换已经能够在许多类型的信号中以可预测的方式实现这一点,无需训练,并且效果最佳。当没有训练数据可用时,可以直接使用曲波变换作为替代,甚至可以将其与深度学习方法一起使用,从而降低模型复杂性和数据需求。
在本次深入探讨中,我们将讲解曲波变换的构建块,重点介绍其直觉。目标是为有兴趣开发使用曲波变换的新应用的用户提供一个起点。可以在Curvelops 仓库中找到本教程的所有代码。
曲波变换之前:傅里叶变换
要理解曲波变换,我们首先需要理解傅里叶变换。2D FFT(快速傅里叶变换)告诉我们图像具有哪种空间频率。较大的(远离原点的)波数(空间频率)上的能量越多,图像沿某个方向变化得越快。在这一部分,我们将理解如何找到这个方向,以及如何量化“快”。
让我们开始创建在某一特定方向上快速变化,但在垂直方向上完全不变化的图像。这将有助于理解𝑘空间谱(傅里叶域的另一名称,即应用 FFT 后的变换域)告诉我们什么。我们将通过在一个垂直于向量𝑣 = (sin 𝜃, cos 𝜃)的方向上调制一个余弦来做到这一点。

图 2. 2D 单色图像,每张图像中的变化方向由红色箭头表示。致谢:自制。
这张图片中的每个图都用箭头指向最大变化的方向𝑣。在与之垂直的方向上,信号完全没有变化。
这并不总是发生,信号可能同时在多个方向上变化。问题是,哪些方向?我们如何找出?用到 FFT 变换。我们来处理其中一张图像,比如 45°的那张,应用 2D FFT。

图 3. 2D FFT 的单色信号在 45°方向上变化最明显。致谢:自制。
在傅里叶域中,我们可以看到信号有两个峰值。对于实数输入信号的 2D 傅里叶变换的一个属性是其傅里叶谱关于原点对称。在上述图中,左上象限携带的信息与右下象限相同;右上象限携带的信息与左下象限相同。因此,我们可以安全地“忽略”一个轴上的负频率。一个惯例是选择“最快”的轴,在这种情况下是垂直(𝑧)轴。
警告:这对于复杂信号并不适用!但这不会改变数学内容,只是视觉解释有所不同。我们在这里只使用实数信号。
因此,我们可以扫描(正𝑘𝑧)𝑘空间以识别单个峰值。其对应的𝑘𝑥和𝑘𝑧坐标为:[6.931, 6.931]。这并不特别有趣,直到我们将该向量归一化为[0.707, 0.707]并与最大变化的原始方向[0.707, 0.707]进行比较。到一个常数,向量是一样的!让我们对所有上述创建的图像尝试一下。
-
计算 2D FFT
-
找到 k 空间中最大幅度的位置
-
(𝑘𝑥-max, 𝑘𝑧-max) 是输入最大变化的方向

图 4. 图 2 中的单色信号的 2D FFT。致谢:自制。
注意到这些向量确实完全遵循数据中最大变化的方向。实际上,如果我们将这些归一化向量叠加到数据空间图像上,我们会得到:

图 5. 图 2 中的单色信号。红箭头:最大变异方向。黄色箭头:从 2D FFT 估计出的最大变异方向。来源:自制。
看起来不错!这和曲波变换有什么关系呢?好吧,想象一下我们尝试将相同的方法应用于以下信号:

图 5. 空间域(左)和傅里叶域(右)中的双色信号。来源:自制。
我们可以看到背景中有一个低频信号,对应于-15°的分量。在其上面,还有一个在另一个方向(15°)的高频分量。如果我们应用我们的𝑘-max 算法,由于低频信号较弱,它不会被选中。我们会认为我们信号的唯一方向是 15°。但是 FFT 谱包含了所有的信息,我们能用它做什么呢?
构建曲波变换
分离尺度
曲波变换通过在不同的尺度上分离信号来处理这些丰富的信息,这些尺度定义为𝑘空间域中的同心区域。这些区域将包含相似的频率。让我们将这一概念应用于上述示例:

图 6. 原始双色信号(左列)。低通信号(中列)。高通信号(右列)。空间域(顶行)。傅里叶域(中行)。傅里叶域中生成顶行信号的掩膜(底行)。深紫色表示零值,猩红色表示单位值。来源:自制。
左上角的信号是原始输入信号。下方的图像(第二行)是其傅里叶谱。下方的图像(第三行)是应用于傅里叶谱以重建原始信号的“掩膜”。它完全是红色的,表示为 1,即谱没有变化。
第二列描绘了来自第一个区域的信号,这些信号是由其掩膜(第三行,中列)定义的低通滤波器构造的。在其将信号置零的地方,掩膜是紫色的。最后,第三列描绘了第二个区域,由一个高通滤波器构造,该滤波器是1 - lowpass。
在曲波变换中,尺度的数量是第一个参数。选择尺度 = 2 会创建一个与上述非常相似的划分(但具有不同的特殊平滑类型)。尺度越多,曲波变换系数越能精确分离具有不同频率的信号。
那么这些系数会是什么呢?在这种情况下,类似于低通和高通滤波器的逆变换,即最上排的两个最右面板。唯一的警告(除了平滑)是较低频率尺度不需要与原始信号相同的采样。确实,低通信号的奈奎斯特频率(特定采样可以表示的最高频率)是原始奈奎斯特频率的一半。这一点从低通信号去除奈奎斯特/2 以上所有频率的事实中可以清楚地看出。因此,我们实际上可以将低通分量的采样提高到原始信号的两倍。这是一个通用规则,我们将看到最粗糙的尺度(尺度 = 0)可以以原始采样的 2ᵐ 倍进行采样,其中 m 等于尺度数减去 1。
最细的尺度(在这个示例中为尺度 = 1)不能以相同的方式处理,因为它的最高频率仍然是原始信号的频率。
为了可视化的目的,我们可以将这种重采样应用到较低的频率尺度,以了解曲线系数在尺度 = 2 且没有额外细分的情况下的样子(稍后会详细说明)。

图 7. 原始信号(左列)。最粗糙的曲线变换尺度使用尺度 = 2(中列)。最细的曲线变换尺度(右列)。所有其他面板类似于图 6. 注意:该曲线变换在最细尺度上有一个小波。致谢:自制。
将这个图与未重采样的原始图进行比较,我们可以注意到信号在两者中都被准确表示,但重采样信号的强度更大。这是因为用于“返回”到空间域的傅里叶变换现在具有一个比原始变换小的归一化因子。因此,信号将是原来的两倍强(2 = √2 × √2,每个维度一个平方根,在这种情况下为两个)。这也是曲线变换和傅里叶变换的一般规则。
让我们将这种分解应用于另一个示例。

图 8. 四种信号组成的四色信号:-30° 和 30° 方向的两个空间频率。像图 7. 这样的面板。致谢:自制。
分离倾角
尽管我们已经在尺度上进行了分离,但我们仍然无法区分两个单独的事件,一个在 -30°,一个在 30°。我们需要另一种类型的细分,将信号(具有类似频率)进一步分割成具有类似方向(或在地球物理学术语中为倾角)的“子信号”。从第二个尺度开始(即最粗糙的尺度之后),我们将进一步将其尺度细分为楔形。这些大致是 𝑘-空间域中的角度区域。最粗糙的尺度(尺度 = 0)不进行这种细分,因为原点无法分配到任何特定的楔形区域。

图 9. 原始四色信号(最左侧列)。信号通过“锥形”在傅里叶域中分离(从中心左侧到最右侧列)。行如图 6、7 和 8. 来源:自制。
我们可以看到我们已经能够将信号分离为两个组成部分。对于这个信号,两个锥形完全为零。从第二列开始的第一行中的信号,本质上就是如果我们使用参数 scales = 2 和 wedges = 8 进行曲线小波变换时曲线小波系数的样子。
一个小的警告是我们实际上只展示了四个锥形。这再次是由于实际信号傅里叶域的对称性。对于复杂信号,我们需要分别处理每个锥形(没有对称的对应物)。
快速离散曲线小波变换
我们现在拥有了理解如何在实践中实现曲线小波变换的所有要素。例如,我们仍然有一些问题需要解决。其中之一是锥形遮罩:如果我们将每个锥形遮罩相加,我们不会在𝑘-空间域的每个网格点上得到精确的 1.0。这意味着我们可能会“丢失”或“产生”信号。更紧迫的问题是性能:这些锥形遮罩被以非最优的方式转换回空间域:我们使用了全尺寸的 FFT 变换来转换𝑘-空间域中的一小片区域。这导致了曲线小波系数与我们的输入图像(在这个例子中为 101 × 101)形状相同。
此外,连续曲线小波变换的一个重要属性是,从曲线小波系数生成输出图像的步骤,应该给出与最初用于获得这些系数的图像完全相同的图像。在数学上,这个变换是单位的,即它的伴随等于其逆。我们的实现没有遵循这一属性。
所有这些问题都可以解决。一个解决所有这些问题的构造是快速离散曲线小波变换,由 Emmanuel Candès、Laurent Demanet、David Donoho 和 Lexing Ying 开发。它最著名的实现由CurveLab 包提供,Curvelops 包通过 Python 封装了它(免责声明:我开发了 Curvelops)。Curvelops 因此提供了一个依赖于PyLops的线性算子接口(免责声明:我是这个库的核心开发者之一)。
让我们通过将 FDCT 应用于当前信号,并将曲线小波系数与之前生成的进行比较。

图 10. (实际)曲线小波系数的四色信号。来源:自制。
这些看起来与我们之前得到的非常相似,有一些区别。首先,如前所述,算法实际上在第二个尺度上输出了 8 个楔形;由于实际输入信号的傅里叶空间对称性,我们将它们合并了。其次,我们可以看到形状与输入信号不同。最粗尺度的大小约为输入信号的⅔。在第二个尺度上,不仅形状不同,纵横比也不同。这是因为不同的方向需要不同的采样。FDCT 以高效的方式处理所有这些问题,并且尊重连续变换的单位性质。
现在我们准备好理解 FDCT 的参数了:
-
nbscales(无默认值,最小值:2):尺度数量。尺度越多,曲线小波变换捕获的细节越精细。更多尺度的缺点包括性能和渐缩问题。通过设置
allcurvelets=False可以缓解这两个问题。 -
nbangles_coarse(默认值:16,最小值:8,必须是 4 的倍数):第二个最粗尺度的角度/楔形数量。请记住,最粗尺度从未被细分。此值仅在第二个尺度中指定,因为它每两个尺度会翻倍(假设第一个尺度有
nbangles_coarse个楔形)。因此,通过设置nbscales=5和nbangles_coarse=8,第二个尺度将有 8 个楔形,第三个尺度有 16 个(如我们目前看到的两个尺度),第四个尺度也有 16 个,最后一个尺度有 32 个(如我们目前看到的四个尺度)。增加此参数的缺点还包括性能和渐缩问题。也可以通过设置allcurvelets=False来缓解这些问题。 -
allcurvelets(默认值:True,布尔值):控制是否对最细(最后一个)尺度进行楔形细分。我们在后面的示例中这样做了,但在前面的示例中没有。从实际的角度来看,尽管默认值为 True,但除非有强烈的理由,否则应该将此选项设置为 False。
快速离散曲线小波变换示例
现在我们理解了这些参数,让我们看看来自不同参数的一些曲线小波系数。

图 11. Python "two-snakes" Logo。来源:Python Software Foundation。

图 12. Python 徽标的曲线小波系数(nbscales=4, nbangles_coarse=8, allcurvelets=False)。来源:自制。

图 13. Python 徽标的曲线小波系数(nbscales=3, nbangles_coarse=8, allcurvelets=True)。来源:自制。
有趣的是,图像的“颜色”仅存在于最粗尺度中,因为该尺度包含零频率,也就是 DC 分量。所有其他尺度仅在初始图像的基础上添加了变化。
曲波变换的另一个有趣的方面是,它的系数很容易解释。它们本质上是原始图像的“片段”,沿某些优选方向变化。
但是,视觉化这些系数可能会很快变得令人不知所措。一种克服这种情况的方法是从单个楔形中提取特征。在接下来的示例中,我们将每个楔形划分为行/列,并计算其能量。这些能量将映射到傅里叶域中的盘中。我们将看到这种可视化如何帮助我们识别图像中每个尺度的事件的优选方向。

图 14。曲波强度盘叠加在 sigmoid 地震模型上的效果。盘的强度应该在局部凹陷方向上最大。来源:自己的工作。
在这里,我们在原始输入信号(sigmoid 地震模型)上绘制了每个尺度/楔形窗口中曲波系数的强度。这些盘模拟了我们上面讨论的楔形划分:离中心越近,尺度越小;角度楔形映射到𝑘-空间域中的同一位置,以极坐标投影呈现。
因此,与我们对𝑘-max 向量的可视化类似,我们应该看到最强的能量方向垂直于优选的局部凹陷。作为提醒,这种情况发生是因为在𝑘-空间中,最强的点是图像在该方向上变化最大的位置。因此,最小变化的方向就是垂直方向。我们可以在图像中识别到这种行为,尤其是在图像的顶部,结构较为规则的地方。
然而,这些盘提供的信息不仅是任意位置的优选凹陷,还能给我们图像的各向异性的一些信息。例如,当没有优选方向或有多个优选方向时,图像更为各向同性。而且,这些信息不仅在空间上局部提供,还按尺度(空间频率)分开提供。
当然,即使这些盘也是曲波变换所包含的完整信息的聚合,这也证明了它的强大和多功能性!
这些只是曲波变换的一些示例。在即将到来的文章中,我们将探讨它们如何用于其他任务!
关键要点
-
图像的傅里叶变换给我们提供了优选变化方向的概念。
-
曲波变换更进一步,告诉我们图像在每个位置如何变化,变化的方向以及空间频率。
-
曲波变换通常通过Python 中的 curvelops 和 CurveLab 提供的 FDCT(快速离散曲波变换)来完成。
-
FDCT 可用于许多领域,如信号处理和深度学习(参见 PyLops 中的 TorchOperator)。
进一步阅读
[1] Candès, E., Demanet, L., Donoho, D., & Ying, L. (2006). 快速离散曲线波变换。多尺度建模与仿真,5(3),861–899。
[2] Ma, J., & Plonka, G. (2010). 曲线波变换。IEEE 信号处理杂志,27(2),118–133。
使用 AI 和计算机视觉检测癌症增长
原文:
towardsdatascience.com/detecting-cancer-growth-using-ai-and-computer-vision-c88e985f450e
AI 为社会带来的好处:医学影像中的应用
·发表于 Towards Data Science ·9 分钟阅读·2023 年 6 月 15 日
--

封面图片来自 unsplash.com
介绍
乳腺癌是女性中最致命的癌症之一。根据世界卫生组织 (WHO),仅在 2020 年,全球就诊断出约 230 万例侵袭性乳腺癌,导致 68.5 万人死亡。
尽管发展中国家占所有乳腺癌病例的一半,但它们占乳腺癌死亡人数的 62%。乳腺癌在诊断后至少存活 5 年的几率从高收入国家的90% 到印度的 66% 和南非的 40%。

图 1:病理学家进行乳腺癌转移检测的各个步骤 | 左上:来自 Camelyon17 challenge 的图像 | 右上:来自 unsplash.com 的图像 | 中央:来自 unsplash.com 的图像 | 左下和右下:作者提供的图像
确定癌症处于哪个阶段的一个关键步骤是通过显微镜检查邻近乳腺的淋巴结,以了解癌症是否转移(医学术语,意为扩散到身体的其他部位)。这个步骤不仅敏感、耗时且劳动密集,还需要高技能的医学病理学家。它影响与治疗相关的决策,包括放疗、化疗和可能的更多淋巴结切除。
随着 AI 和计算机视觉技术的发展,特别是卷积神经网络(CNNs)的进步,我们已经能够提高在图像识别、目标检测和分割等各种计算机视觉任务中的准确性。这些进步在解决一些最具挑战性的医疗问题方面非常有帮助,尤其是在医疗设施有限的地区。
基于此,本文将介绍一个利用最先进的 CNNs 和计算机视觉技术的框架,帮助检测淋巴结中的转移。一个成功的解决方案有望减轻病理学工作的负担,同时减少诊断中的主观性。
方法论和方法
给定一张全切片图像的淋巴结切片,我们的目标是生成一个掩模,标识切片中潜在的癌变区域(肿瘤细胞)。图 2 展示了一个例子,图中显示了切片上的组织图像以及一个掩模,其中黄色区域表示组织中癌变的区域。

图 2:左:数据集中的 WSI | 右:二值掩模,黄色区域表示癌变区域 — 图片由作者提供
图像分割是经典的计算机视觉任务之一,其目标是训练神经网络输出图像的像素级掩模(类似于图 2 中的掩模)。有多种深度学习技术可用于图像分割,这些技术在这篇论文中有详细描述。谷歌的TensorFlow也提供了一个很好的教程,使用编码器-解码器方法进行图像分割。
我们将把这个问题视为一个二分类问题,而不是使用常用于图像分割问题的编码器-解码器方法,其中每个自定义定义的区域都通过神经网络分类为健康或肿瘤区域。这些全切片图像的单独区域可以拼接在一起形成所需的掩模。
我们将使用标准的 ML 过程来构建 CV 模型:
数据收集 → 预处理 → 训练-测试拆分 → 模型选择 → 微调与训练 → 评估
数据收集与预处理
数据集来源于 CAMELYON16 Challenge,根据 挑战网站 包含了来自 Radboud University Medical Center (荷兰奈梅亨) 和 University Medical Center Utrecht(荷兰乌特勒支)的 400 张全切片图像(WSI)。
整张幻灯片图像以多分辨率金字塔结构存储,每个图像文件包含多个下采样版本的原始图像。金字塔中的每个图像都以一系列瓷砖的形式存储,以便快速检索图像的子区域(参见图 3 以获取说明)。
有关全切片成像的更多信息,请参见 这里。
幻灯片的真实标签提供为 WSI 二进制掩模,指示幻灯片中含有肿瘤细胞的区域(参见上图 2 作为示例)。

图 3:全切片图像(WSI)中不同放大级别的说明。图像来源于 camelyon16.grand-challenge.org/Data/
我们数据集中 WSIs 有 8 个缩放级别,允许我们将图像从 1x 放大到 40x。级别 0 被认为是最高分辨率(40x),级别 7 是最低分辨率(1x)。
由于其庞大的尺寸(我们数据集中每个 WSI 的大小都超过 2GB),标准的图像工具无法将其读取并压缩到系统 RAM 中。我们使用了 OpenSlide 库在 Python 中的实现来高效地读取数据集中的图像,并提供了跨不同缩放级别导航的接口。

图 4 - 作者提供的图像
在包含 400 张 WSI 的整个数据集上训练 CNN 计算开销非常大(想象一下在 2 x 400 = 800GB 数据集上训练)。我们使用了 Google Collab 的免费版,但 GPU 支持有限。因此,我们从数据集中随机抽取了 22 张 WSI。起初,22 张图像可能看起来像是一个较小的数据集,难以准确训练卷积神经网络,但正如我之前提到的,我们从这些庞大的 WSI 中提取小补丁,并将每个补丁视为可以用于训练我们模型的独立图像,如图 5 所示。

图 5:每个 WSI 进一步拆分为更小的补丁以增强数据集 — 作者提供的图像
在最高的缩放级别(级别 0 = 40 倍缩放)下,每张图像的尺寸大约为 62000 x 54000 像素——提取 299 x 299 大小的补丁将从每个 WSI 中提供约 35,000 张单独的图像。我们从每个缩放级别提取补丁。随着缩放级别的增加,分辨率降低,我们可以从 WSI 中提取的补丁数量也减少。在级别 7 时,我们每张图像只能提取少于 200 个补丁。
此外,每个 WSI 有很多空白区域,其中没有组织细胞。为了保持数据的准确性,我们避免了组织细胞不足 30%的补丁,这些是通过对灰色区域的密集计算得到的。
数据集被平衡为大约包含相同数量的健康和肿瘤细胞的补丁。在这个最终数据集上进行了80–20 的训练-测试分割。
模型训练
我们构建了多个 CNN 模型,这些模型在使用前一节中描述的机制生成的图像补丁上进行了训练。
目标函数
我们的主要优化目标是sensitivity and recall,但我们也密切监控接收器工作特征曲线(ROC)的曲线下面积(AUC),以确保我们不会产生过多的假阳性。
在癌症检测的背景下,最重要的是最小化假阴性的数量,即模型错误地将癌症样本分类为非癌症样本的实例。假阴性的数量过高可能会延迟真正患有癌症患者的诊断和治疗。灵敏度(或召回率)衡量的是实际阳性样本被正确识别的比例,通过优化高召回率,我们旨在正确识别尽可能多的实际阳性病例。
然而,单纯关注灵敏度可能导致模型将大多数样本预测为阳性,从而增加假阳性的数量(将非癌症样本错误分类为癌症样本)。这是不理想的,因为它可能导致不必要的医疗干预,并引起患者的不必要焦虑。这就是监控 AUC-ROC 变得极其重要的地方。
模型构建
我们从构建基线模型开始,该模型是一个非常简单的架构,由 2 个 卷积层 和 最大池化 以及 dropout 正则化 组成。为了改进基线,我们在我们的数据集上微调了先进的图像识别模型,如 VGG16 和 Inception v3。
由于我们有不同缩放级别的图像,我们训练了多个模型,每个模型使用来自一个缩放级别的图像,以查看在特定缩放级别查看图像是否能提升网络性能。由于在较低缩放级别提取的补丁数量有限——这些缩放级别的 3、4、5 张图像被合并为一个训练集。针对 0、1 和 2 缩放级别的图像分别构建了单独的模型。

图 6:标准 Inception v3 模型附加了全局最大池化层和 Sigmoid 激活。Inception v3 图像来源:cloud.google.com/tpu/docs/inception-v3-advanced
有趣的是,表现最佳的模型是预训练的 Inception v3 模型,使用了 ImageNet 权重,并添加了一个 全局最大池化 层(见图 6)。Sigmoid 激活 函数将任何范围的实数压缩到 0 和 1 之间。这在我们的场景中特别有用,因为我们希望将预测映射到两个类别(0 和 1)的概率上。
模型配置
我们进行了交叉验证,以确定模型的最佳超参数。下面展示了我们增强版 ImageNet v3 的最终配置,包括所用的优化器、学习率、rho、训练轮次和批量大小。通过使用类别权重,我们增强了模型对少数类(肿瘤病例)的关注,提高了其正确识别和诊断癌症病例的能力,这是在这个关键健康背景下的基本要求。

图 7:模型配置和超参数——图像由作者提供
模型评估
我们查看了不同超参数的训练运行的损失、AUC 和召回率,并在不同缩放级别上取样了图像补丁。
如前所述,在 3、4、5 个缩放级别下的图像被合并为一个训练集,并为 0、1 和 2 个缩放级别的图像建立了不同的模型。下面的图表显示了不同缩放级别在验证集上的表现。根据修改后的 Imagenet v3,缩放级别 1 的 AUC 和召回率表现最佳。

图 8:最终微调模型的配置和性能 — 作者提供的图片
推理
一旦模型经过微调,我们可以使用它为任何新的全切片图像生成“掩模”。为此,我们首先需要从感兴趣的缩放级别(第 1 级或第 2 级)生成 299 x 299 分辨率(标准 Imagenet v3 架构的输入大小)的图像块。
这些单独的图像随后会通过经过微调的模型进行分类,判断每张图像是否包含肿瘤细胞或非肿瘤细胞。然后将这些图像拼接在一起以生成掩模。
这里展示了我们测试集中的两个全切片图像的输出结果和实际掩模。正如你所见,我们模型输出的掩模与实际掩模相当相似。


图 9:模型在测试集中的几张图像上的结果 — 作者提供的图片
总结
在这篇文章中,我们探讨了计算机视觉模型如何通过微调来检测 gigapixel 病理图像中的癌症转移。下面的图像总结了模型训练的工作流程和分类新图像的推理过程。

图 9:训练和推理工作流程的总结 — 作者提供的图片
将该模型整合到现有的病理学家工作流程中,可以作为辅助工具,特别是在资源有限的机构中具有很高的临床相关性,也可以作为及时诊断潜在疾病的第一道防线。
需要进一步工作来评估对实际临床工作流程和患者结果的影响。尽管如此,我们对经过细致验证的深度学习技术与精心设计的临床工具的积极前景保持乐观,这些技术和工具有可能提升全球病理诊断的精准度和可及性。
请查看我在 Github 上的源代码: https://github.com/saranggupta94/detecting_cancer_metastasis.
你可以在这里找到 CAMELYON 竞赛的最终结果: https://jamanetwork.com/journals/jama/article-abstract/2665774
如果你对合作项目感兴趣或想要联系我,可以随时通过 LinkedIn 与我联系,或者发邮件至 saranggupta94@gmail.com。
感谢 Niti Jain 对本文的贡献。
检测协变量偏移:多变量方法指南
原文:
towardsdatascience.com/detecting-covariate-shift-a-guide-to-the-multivariate-approach-c099bd1891b9
MLOps
好的旧 PCA 可以在生产数据的分布发生变化时提醒你
·发布于 Towards Data Science ·阅读时长 16 分钟·2023 年 1 月 17 日
--

任何机器学习模型的最终目的都是为其所有者带来价值。通常,这种价值表现为算法比人类更好或更快地完成任务(或两者兼而有之)。开发和部署模型的投资成本通常很高。为了收回这笔投入,模型需要在生产中提供足够长时间的价值。协变量偏移可能会妨碍这一点,它是一种导致模型在生产中随时间退化的现象。让我们来看看如何检测它,以及为什么流行的简单方法通常不够好。

介绍协变量偏移
协变量偏移是指模型的输入特征在生产中的分布与模型在训练和验证时看到的分布发生变化的情况。
协变量偏移是指模型输入在训练数据和生产数据之间分布的变化。
在大多数应用中,协变量偏移发生是时间问题。例如,如果你在建模客户,他们的行为模式会随着经济变化、年龄增长或由于营销活动而改变客户基础。生活中唯一不变的就是变化,正如希腊哲学家赫拉克利特所说的。
确保模型在生产中持续有效的关键是及早检测协变量偏移。我们该如何做到这一点呢?让我们来探讨一下!

检测协变量偏移:一种天真的方法
我们已经提到协变量漂移是模型输入分布的变化。我们知道训练数据中每个特征的分布,我们也应该能够获取生产中的特征分布。那我们为什么不直接比较这两者呢?
实际上,这是一个有效的方法。像 TensorFlow 数据验证或Great Expectations这样的工具可以让我们轻松地逐特征比较训练数据和生产数据之间的分布。
确定给定特征的训练分布和生产分布是否不同的最简单方法是比较它们的摘要统计量,如均值、中位数等。
更复杂的方法是直接计算这两个分布的差异,使用如地球搬运工距离或詹森-香农散度这样的(不)相似性度量。
更加统计学严格的方法是进行适当的假设检验。连续特征的Kolmogorov-Smirnov 检验和分类特征的卡方检验可以帮助我们确定特征分布是否以显著的方式不同。
然而,后一种方法存在一个严重的缺点。像这样测试生产数据需要不断地对大数据量进行许多测试。许多测试肯定会发出显著效果的信号,但从监控的角度来看,这并不总是值得担忧的原因。在许多情况下,统计上显著的分布变化并不一定对应于导致模型性能下降的协变量漂移。
但是,还有一个更显著的缺点是上述所有方法都共享的:它们是单变量的,这意味着它们将每个特征与其他特征隔离开来处理。在现实生活中,我们经常观察到多变量数据漂移,即单个特征的分布不一定发生变化,但它们的联合分布会发生变化。考虑以下示例。

当单变量漂移检测失败时
我们有一个由两个特征组成的数据集:年龄和收入,数据是在几周内收集的。

收入与年龄。作者提供的图像。
两个特征已经被标准化,以便可以整齐地一起可视化。标准化使用了来自总体的均值和方差值,即未从这些数据计算的,这意味着我们不需要担心任何数据泄漏。
在第 10 周(蓝色),两个特征之间有相当强的正相关。这是有道理的:大多数人随着年龄增长获得更多经验,他们也得到晋升,这导致平均收入更高。
在第 16 周(黄色),每个特征的边际单变量分布仍然是标准正态分布,但相关模式发生了剧烈变化;现在,两个特征之间呈负相关。也许第 16 周的数据是从年长的人那里收集的。在这种情况下,年纪越大,越有可能退休,因此收入较低。
当单变量特征分布保持不变而联合分布发生变化时,单变量漂移检测方法会失败。
我们显然正在经历强烈的协变量漂移;任何基于第 10 周数据训练的机器学习模型在第 16 周的数据上表现都会非常糟糕。然而,我们之前讨论的单变量漂移检测方法不会提醒我们这一危险的变化。

检测协变量漂移:一种多变量方法
为了可靠地检测任何情况下的协变量漂移,我们需要(在单变量方法之外)另一种能够捕捉所有特征联合分布变化的方法。一种非常简单但巧妙的方法是基于经典的主成分分析(PCA)。
主成分分析
主成分分析是一种降维技术。它识别出数据中包含最多方差的方向。这些方向称为主成分,按捕获的方差从高到低排序。这意味着通过限制自己选择前几个主成分,我们可以捕捉数据的大部分变异性,同时减少维度。换句话说,我们可以说 PCA 将数据投影到一个低维空间。
PCA 将原始数据映射到低维空间,保留了大部分信息信号。
PCA 的降维在多个应用中都找到了用武之地。在模型训练之前进行降维处理可以减少特征数量,因为一些机器学习模型在特征过多时训练速度变得非常慢。
但使用 PCA 作为预处理步骤获得的好处不仅仅是速度。在某些情况下,基于 PCA 转换数据训练的机器学习模型表现更好。这是因为当我们降低维度时,我们试图将相同数量的信息挤入更少的变量中。这个过程是有损的,这意味着一些信息将不可避免地被丢弃。PCA 假设数据集的有用信息由其方差捕捉,因此使用捕捉大部分方差的独立主成分作为特征可以带来更好的决策阈值。
最后,PCA 对于可视化和发现多维数据集中的模式也非常有用——在将数据压缩为两个或三个特征后,我们可以轻松地绘制这些特征,用一个感兴趣的属性为每个数据点着色,并发现有趣的模式。
数据重建与 PCA
好吧,但我听到你在问,这种降维如何帮助漂移检测。这里是想法。
我们唯一缺少的拼图是 PCA 变换是可逆的。一旦我们将数据压缩为较少的特征,我们可以使用 PCA 模型来解压数据,即:将数据集恢复到其原始特征数量。由于有损压缩过程,结果可能不会与原始数据完全相同,但应该非常相似。我们将原始数据与解压数据之间的差异称为重建误差。
让我们回到我们的示例数据集。回忆一下,我们将第 10 周与第 16 周进行了比较,以发现期间发生了多变量数据漂移。现在,让我们关注第 9 周、第 10 周和第 11 周,在这段时间内没有发生协变量漂移,也就是说:这些三周内特征的联合多变量分布是相同的。
如果我们在第 9 周学习了 PCA 映射,我们可以使用它来压缩和解压第 10 周和第 11 周的数据,从而获得类似的、较低的重建误差。

没有协变量漂移的 PCA 重建误差。图像由作者提供。
我们现在实际上不关心误差的具体数值。重要的是,第 10 周和第 11 周的误差可能大致相同,因为数据的内部结构保持不变,PCA 学到的压缩-解压映射仍然适用。
现在,如果我们在第 10 周和第 16 周而不是第 10 周和第 11 周使用我们的 PCA 模型,会发生什么呢?这次,PCA 学到的映射仍然适用于第 10 周,但不适用于第 16 周。因此,第 16 周的重建误差将显著大于第 10 周的重建误差。

带有协变量漂移的 PCA 重建误差。图像由作者提供。
我们刚刚讨论的想法可以用来检测生产中的协变量漂移。如果我们知道在没有漂移的情况下数据的重建误差(考虑第 10 周的误差),我们可以将其与生产数据的重建误差进行比较。如果后者显著较大,我们可以声明存在协变量漂移。
我们可以通过比较生产数据的 PCA 重建误差与其预期水平来检测协变量漂移。
唯一剩下的两个问题是:什么是预期重建误差,以及显著较大意味着什么?
比较重建误差
估计期望重建误差的一种方法是取一部分我们知道没有协变量漂移的训练数据(称为参考数据集),将其拆分为块,并计算每个块的重建误差。这样,我们得到一系列误差值,从中可以计算均值和方差。
然后,声明协变量漂移的一个简单经验法则是检查生产数据中的重建误差是否超出了从参考集获得的范围。如果它偏离参考均值至少三个标准差,那么很可能发生了漂移。
Python 中的多变量漂移检测
现在,让我们看看如何使用 NannyML 包在 Python 中实现这些想法。
我们将使用 Yandex 的天气数据集。多变量概念漂移在天气测量中固有存在;随着季节的更替,不同气候组件相互作用的方式也往往会发生变化。数据集包含超过一百个描述各种气象测量的特征,如温度、湿度、气压等。
首先,让我们加载和准备数据,并将其拆分为两个不相交的集合:九月数据(我们的参考集,用于训练 PCA)和十月数据(我们的分析集,用于测试协变量漂移)。我们还需要将时间戳列解析为适当的 pandas datetime 格式。
import nannyml as nml
import pandas as pd
df = pd.read_csv("weather/shifts_canonical_train.csv")
df["timestamp"] = pd.to_datetime(df.fact_time * 1_000_000_000)
df_september = df.loc[
df["timestamp"].apply(lambda x: x.month) == 9
].sort_values("timestamp")
df_october = df.loc[
df["timestamp"].apply(lambda x: x.month) == 10
].sort_values("timestamp")
接下来,我们使用 NannyML 的漂移计算器,该计算器完成上述所有步骤。我们需要传递要检查的特征名称(除了时间戳列之外的所有列)、时间戳列名称,以及根据其估计期望重建误差的块大小。然后,我们简单地将其拟合到参考数据上,并将其应用于分析数据以测试协变量漂移。
calc = nml.DataReconstructionDriftCalculator(
column_names=df_september.columns.drop("timestamp"),
timestamp_column_name="timestamp",
chunk_size=10_000
)
calc.fit(df_september)
results = calc.calculate(df_october)
利用生成的结果对象最简单的方法是将其渲染为图表。
figure = results.plot(plot_reference=True)
figure.write_image("reconstruction_error_weather.jpg")

生产数据中的重建误差与其预期范围的对比。图片由作者提供。
两条虚线表示从九月数据估计的重建误差值的预期区间。如你所见,月末附近有一个异常观测值——可能是一次特别强烈的雷暴或其他有影响的事件。
在十月的头几天,没有协变量漂移;似乎这个月的开始天气与九月最后一周的天气类似。但随后秋天来临,十月剩余的数据特征表现出明显的协变量漂移。
尽管上面的图表清晰且视觉上很吸引人,但它不适合用作带有自动警报的管道中检测协变量漂移的便捷方法。但是,只需对results对象调用.to_df()方法即可获取包含图表背后所有数据的数据框。
results.to_df()
chunk ... reconstruction_error
key chunk_index ... lower_threshold alert
0 [0:9999] 0 ... 6.15187 False
1 [10000:19999] 1 ... 6.15187 False
2 [20000:29999] 2 ... 6.15187 False
3 [30000:39999] 3 ... 6.15187 False
4 [40000:49999] 4 ... 6.15187 False
.. ... ... ... ... ...
82 [390000:399999] 39 ... 6.15187 True
83 [400000:409999] 40 ... 6.15187 True
84 [410000:419999] 41 ... 6.15187 True
85 [420000:429999] 42 ... 6.15187 True
86 [440000:439999] 43 ... 6.15187 True
[87 rows x 14 columns]
它包含一个 alert 列,其中包含一个布尔标志,表示每个数据点是否超出了预期范围。这是一种将协变量漂移检测添加到现有数据验证检查中的好方法!

假设与限制
对于任何统计方法的讨论,都不应该遗漏对其假设的深入探讨。这将给我们一些指示,了解方法何时有效,何时无效,以及它的局限性。基于 PCA 重建误差变化的数据漂移检测也不例外。
数据完整性
首先,我们的算法需要处理数据集中可能存在的缺失值。这一要求来自于基础 PCA,它假设所有数据点都被观测到,以便能够找到主成分。
显而易见的解决办法是在进行漂移检测之前对缺失值进行填充。实际上,我们之前使用的 DataReconstrucionDriftCalculator 类接受两个参数 imputer_categorical 和 imputer_continuous,通过这两个参数我们可以指定填充缺失数据的方法。我们只需传递一个 scikit-learn 的 SimpleImputer 实例,如下所示:
calc = nml.DataReconstructionDriftCalculator(
column_names=df_ref.columns.drop("date"),
timestamp_column_name="date",
chunk_size=1000,
imputer_categorical=SimpleImputer(strategy="constant", fill_value="NA"),
imputer_continuous=SimpleImputer(strategy="median"),
)
然而,采用这种方法存在风险。scikit-learn 的 SimpleImputer 是非常简单的。撰写本文时,它只能执行四种简单的基于捐赠者的填充方法;它用列中其他值的均值、中位数或众数,或指定的常数值替代缺失值。
如果你参加过我的 DataCamp 数据填充课程或阅读过我关于这一主题的博客文章,那么你就会知道这些填充策略并不是最佳选择。均值、中位数、众数或常数填充都存在两个相同的问题:它们减少了均值填充值变量的方差,并破坏了这些变量与其他数据之间的相关性。这可能会对我们造成两次伤害:首先,当我们运行基于数据方差的 PCA 方法时,其次,当我们寻找联合数据分布的变化时,这种变化可能会受到填充值的影响。
我们的漂移检测器不允许缺失值。如果存在,它们应该提前填充,最好使用基于模型的方法。
因此,可能更好的解决方案是在进行漂移检测之前使用模型基础的填充方法,如 MICE。
在没有漂移的情况下,重建误差的稳定性
我们的漂移检测器还做了一个重要假设,即在没有协变量漂移的情况下,重建误差是稳定的。让我们试着解析一下这个说法。
让我们回到算法的后续步骤。我们首先在第 9 周的数据上学习 PCA 映射。然后,我们说可以使用这个映射来压缩和解压缩来自第 10 周和第 11 周的数据(两者均无漂移),得到的重构误差将低且相似。我们之前没有提到的一点是,在用于学习 PCA 映射的相同数据上计算的误差会更低。当我们在数据发生漂移的第 16 周进行此操作时,我们会发现重构误差更高。

随时间推移的重构误差。图片来自作者。
重构误差稳定性的假设指的是,虽然误差从第 9 周增加到第 10 周,但在接下来的几周中保持稳定,直到发生协变量偏移。让我们看看为什么这个假设是合理的。
当我们对第 9 周的数据进行 PCA 拟合时,我们在多维数据空间中寻找那些捕捉最大方差的方向。然后,我们可以使用这些主成分在相同的数据上获得一些重构误差值,称之为RE9。
当我们使用相同的主成分来计算第 10 周的重构误差时,我们会发现RE10大于RE9。这是因为第 9 周的组件可能不是第 10 周数据的最佳选择(即没有捕捉到最多的方差),而第 10 周的数据具有不同的噪声模式。这种效应被称为回归均值。这类似于机器学习模型通常在训练集上的表现优于测试集。
关键假设是我们期望RE10 ≈ RE11,所以第 11 周的误差应接近第 10 周的误差。这使我们能够发现漂移;我们假设第 10 周、第 11 周、第 12 周等的误差保持稳定,直到误差上升,这将提醒我们可能存在漂移。
我们的漂移检测器假设在没有协变量偏移的情况下,重构误差会随着时间保持稳定(除了用于学习 PCA 映射的时期)。
这个假设的理由是,虽然RE9因过拟合于特定的第 9 周数据而被低估,但RE10是我们预期看到的每个新数据样本的预期重构误差(前提是其联合分布保持不变)。
偏移(非)线性
我们方法中最重要的限制之一是 PCA 重构误差能够捕捉和无法捕捉的漂移类型。即,我们所做的协变量偏移检测不能捕捉到存在非线性变换的漂移,这种变换保持特征之间的相关性,同时保持每个特征的均值和标准差。
基于 PCA 的漂移检测器在存在非线性漂移且保持每个特征的均值和标准差以及不同特征之间的相关性时将不起作用。
让我们用一个简单的例子来说明。假设我们有一个仅包含两个特征 x 和 y 的参考数据集。仅使用两个维度可以让我们轻松地可视化它们之间的关系。

假设的参考集。图像由作者提供。
显然,我们的两个特征之间存在非随机的关系。现在,设想在某个时刻,这种关系变化为以下的形式。

假设分析集。图像由作者提供。
y 的可能值范围增加了,而在联合分布的中间存在一个低密度区域,其中没有数据点。
注意,这个新数据集的线性相关系数几乎与上面的参考数据集相同。如果我们在这些数据上运行漂移检测器,结果将如下:

对于具有非线性协变量漂移且 PCA 无法捕捉的数据集的漂移检测。图像由作者提供。
检测器未能捕捉到我们知道已经发生的变化:蓝色误差线在预期范围内。这是因为漂移没有改变特征的均值和标准差,也没有改变特征之间的相关性。
我们可以将这个论点推向极端。设想我们的分析数据集呈现出恐龙的形状(本节讨论的所有数据来自于Datasaurus set:这是一个包含相同线性相关性和基本统计量的 x-y 对的集合,但具有非常不同的联合分布)。

假设的恐龙分析集。图像由作者提供。
只要线性相关性以及特征的均值和标准差保持不变,基于 PCA 的漂移检测器将不起作用。

对于具有非线性协变量漂移且 PCA 无法捕捉的数据集的漂移检测。图像由作者提供。
在我们的二维示例中,我们可以很容易地观察到数据的联合分布。然而,在大多数实际应用中,我们处理的是更多特征,这使得判断漂移是否线性变得更加困难。
如果你的特征不多,我建议查看它们的总结统计量以及每对特征之间的线性相关性。如果它们在参考数据和分析数据之间类似,这表明基于 PCA 的漂移检测可能不可靠。
虽然通过 PCA 数据重建来检测协变量漂移不能检测到我们刚刚讨论的一些漂移情况,但我们可以使用单变量漂移检测方法来检测这些情况。敬请关注有关它们的单独文章!
这篇文章也发表在 NannyML 博客上。

感谢阅读!
如果你喜欢这篇文章,为什么不订阅电子邮件更新以获取我的新文章?通过成为 Medium 会员,你可以支持我的写作,并无限制地访问所有其他作者和我自己的故事。
想始终保持对迅速发展的机器学习和人工智能领域的关注吗?查看我的新通讯,AI Pulse。需要咨询?你可以在这里向我提问或预约一对一咨询。
你还可以尝试阅读我的其他文章。难以选择?试试这些:
特征选择方法及其选择方式 [## 特征选择方法及其选择方式
特征选择的原因、方法和时机,加上一些实用的技巧和窍门
关于贝叶斯思维在日常生活中的重要性 [## 关于贝叶斯思维在日常生活中的重要性
这种简单的思维转变将帮助你更好地理解你周围的不确定世界。
关于置信区间与预测区间的区别 [## 置信区间与预测区间
混淆这两者可能会很昂贵。了解它们的不同之处以及何时使用每一种!
检测生成式人工智能内容
关于 deepfakes、真实性以及总统关于人工智能的行政命令
·
跟随 发表在 Towards Data Science ·8 分钟阅读·2023 年 11 月 15 日
--
你能识别出假的吗?由 Liberty Ann 拍摄的照片在 Unsplash
人工智能生成技术进步带来的众多有趣的伦理问题之一是模型生成产品的检测。对于那些消费媒体的我们来说,这也是一个实际问题。我正在阅读或观看的内容是一个人深思熟虑的作品,还是仅仅是概率生成的文字或图像,旨在吸引我?这是否重要?如果重要,我们该怎么办?
不可区分内容的含义
当我们谈论内容难以或不可能检测为 AI 生成时,我们实际上是在进入类似于图灵测试的领域。假设我给你一段文本或一张图像文件。如果我问你,“这是人类还是机器学习模型生成的?”而你无法准确判断,那么我们就到了需要考虑这些问题的地步。
在许多领域,我们接近这一点,特别是对于 GPT-4,但即使是较不复杂的模型,也取决于我们使用什么样的提示和上下文的量。如果我们有一份来自 GPT 模型的长文档,那么检测它不是人类生成的可能会更容易,因为每个新词都是模型做一些普通人不会做的事情的机会。视频或高分辨率图像也是如此——像素化或“恐怖谷”出现的机会越多,我们就越容易发现伪造内容。
我也很清楚,随着我们对模型生成内容的熟悉程度提高,我们将会更擅长识别内容中 AI 参与的明显迹象。正如我几周前在解释生成对抗网络(GAN)如何工作的文章中所描述的那样,我们和生成 AI 之间存在某种 GAN 关系。模型致力于创造尽可能类似人类的内容,而我们则提高识别这些内容非人类的能力。这就像一场比赛,双方都在努力超越对方。
随着我们对模型生成内容的熟悉程度提高,我们将会更擅长识别内容中 AI 参与的明显迹象。
检测方法
然而,我们在这种检测方面可能会有一个极限,模型最终可能会超越普通人眼耳(如果尚未如此的话)。我们只是没有像大型模型那样的感知能力和模式识别复杂性。幸运的是,我们也可以将模型作为我们这边比赛的工具,训练模型来审查内容并确定其是否由人类生成,这也是我们的一种工具。
然而,在某些情况下,尤其是小量内容中,可能真的没有任何可靠的迹象表明其来源于机器学习。从哲学上讲,考虑到模型的无限进步,可能有一个点在于这两种内容之间真的没有实际差别。此外,大多数人不会使用模型来测试我们消费的所有媒体,以确保其由人类生成且真实。作为回应,一些组织,如内容真实性倡议,正在推动内容认证元数据的广泛采用,这可能会有所帮助。然而,这些努力需要模型提供者的善意和工作。
然而,在某些情况下,某些内容可能真的没有任何可靠的迹象表明其来源于机器学习。
你可能会问,那么,有些故意恶意行为者试图使用深度伪造或使用 AI 生成的内容制造伤害的人怎么办呢?他们不会自愿识别他们的内容,对吧?这是一个公平的问题。然而,至少目前,那些足够复杂以至于能够大规模欺骗人们的模型大多受到大公司(如 OpenAI 等)的控制。这种情况不会持续太久,但就目前而言,如果向公众提供最先进的 LLM 的人们采取一些行动,至少会在内容来源的问题上起到一定作用。
到目前为止,这不是一个非常乐观的故事。生成式 AI 正迅速向一个使得那些非常强大的模型足够小,以至于恶意行为者可以运行自己的模型,并且这些模型很容易创建出与有机人类内容在字面上无法区分的内容的地方发展。即使是其他模型也无法分辨。
检测的原因
我有点过于急躁了。但是,为什么大家都如此重视我们首先要弄清楚内容是否来自模型呢?如果你分辨不出来,那有什么关系呢?
一个主要原因是,内容的传播者可能有恶意意图,例如误导信息或深度伪造。在这里,创建图像、音频和视频是最常见的情况之一——使其看起来像某人说过或做过某事。如果你一直在关注美国总统关于人工智能的行政命令,你可能已经听说拜登总统实际上因为听说有人可能会恶意使用他的肖像和声音来进行误导信息而对此感兴趣。这是一个非常严重的问题,因为目前我们倾向于相信我们用自己的眼睛看到的图像或视频,这可能会对人们的生活和安全产生重大影响。
目前,我们倾向于相信我们用自己的眼睛看到的图像或视频,这可能会对人们的生活和安全产生重大影响。
另一个相关问题是,当模型用于模仿特定人类的作品时,不一定是出于恶意原因,而仅仅是因为那份工作令人愉悦和受欢迎,可能也是有利可图的。这是明显不道德的,但在大多数情况下可能并不是要积极伤害观众或被模仿的人。当深度伪造用于虚构某人的行为时,这也会造成声誉损害。(只需问问乔·罗根,他一直在打击使用他的肖像进行深度伪造的广告。)
我在 Casey Newton 在他10 月 5 日的 Platformer 期刊讨论后开始思考的第三个角度是,公众人物可能会将问题颠倒过来,声称他们不良行为的真实、真实证据是人为生成的。当我们无法通过证据可靠地揭露不当行为时,该怎么办,因为“这是深度伪造”是一个无法反驳的回应?我们还未完全到达这一点,但我可以预见这在不久的将来可能成为一个真正的问题。
当我们无法通过证据可靠地揭露不当行为时,该怎么办,因为“这是深度伪造”是一个无法反驳的回应?
不那么紧迫的是,我也认为希望我的媒体消费能代表与另一个人互动,即使这是主要是单向的思想交流。我认为阅读或欣赏艺术是与另一个人的思想互动,而与模型编排的文字互动却没有相同的感觉。因此,就个人而言,我希望知道我消费的内容是否是由人类创作的。
行政命令
鉴于所有这些非常现实的风险,我们面临着一些严重的挑战。似乎在可检测来源(即,公众安全和我上述描述的所有问题)和模型复杂性之间存在权衡,作为一个行业,数据科学正朝着模型复杂性方向推进。谁来平衡这些尺度呢?
总统的行政命令在这个话题上代表了一些重要的进展。(它还讨论了许多其他重要问题,我可能会在其他时候讨论。)我花了过去一个半星期时间思考这一行政命令,并阅读了来自行业各方的观点。虽然有些人认为这会抑制进步(并且会使生成型 AI 的大玩家得以巩固,而小型竞争者则受到牺牲),但我认为我倾向于对这一行政命令持乐观态度。
创建具有竞争力的生成型 AI 模型是极其昂贵且资源密集的,这自然限制了最初有多少竞争者能够进入这一领域。以牺牲更广泛的社会安全来保护这一领域的假设新玩家对我来说没有意义。我也不认为该行政命令对那些有资源进入这一领域的组织来说是过于繁琐的。
命令本身也不是那么具备规范性。它要求创建一些东西,但对如何实现这些目标留有很大弹性,希望有见识的人会参与这些过程。🤞(领域中的数据科学家应该留意发生的情况,如果情况失控,就要积极发声。)特别是,它要求商务部制定“检测 AI 生成内容和验证官方内容的标准和最佳实践”。还有一些关于安全性和模型相关的重要组成部分。
我是否对我们的政府在监管 AI 的同时不损害创新方面有极大的信任? 不,真的没有。 但我相信,如果让行业自行其是,它不会像所需的那样关注来源和内容检测的问题——到目前为止,他们还没有表现出这是一个优先事项。
我相信,如果让行业自行其是,它不会像所需的那样关注来源和内容检测的问题。
同时,我不确定检测生成性 AI 生成的内容在所有或大多数情况下是否实际上是可能的,特别是随着我们取得进展。行政命令没有提及如果内容跨越到不可检测的领域,是否阻止开发这些模型,但这种风险让我担忧。这真的会扼杀创新,我们需要仔细考虑权衡是什么或可能是什么。尽管如此,这匹马可能已经跑出了那座特定的马厩——如此多的开源生成性 AI 模型已经存在于世界上,不断在改进它们的能力。
结论
这个话题很棘手,正确的行动并不一定很明确。模型输出越复杂,我们检测这些输出不是人类生成的可能性就越高,但我们正处于一个技术竞赛中,这将使得检测越来越困难。对这一话题的政策参与可能会给我们一些保护措施,但我们还不能确定这是否真的有帮助,或者是否会变成一场笨拙的灾难。
这是其中一个我无法将讨论 neatly tie up 的时候。不可区分的生成性 AI 输出的潜在和实际风险是严重的,应当如此对待。然而,我们处于一个科学/数学的阶段,无法创造出快速或简单的解决方案,我们需要重视更先进的生成性 AI 可能带来的好处。
尽管如此,数据科学家应当花时间阅读行政命令或至少阅读事实清单,并清楚了解声明的含义。正如常读者所知,我认为我们必须对在生活中向外行传播准确且易于理解的信息负起责任,而这是一个好机会,因为该话题正在新闻中。确保你为周围的数据科学素养作出积极贡献。
查看我的更多作品 www.stephaniekirmer.com。
参考文献
Casey Newton - 10 月 5 日的 Platformer 期刊
制定数字内容来源的标准。
contentauthenticity.org](https://contentauthenticity.org/?source=post_page-----286200498f93--------------------------------)
使用 Python 检测实际数据中的幂律
原文:
towardsdatascience.com/detecting-power-laws-in-real-world-data-with-python-b464190fade6
详细解析基于最大似然的方法,并附示例代码
·发表在Towards Data Science ·10 分钟阅读·2023 年 11 月 24 日
--

图片由Luke Chesser拍摄,来源于Unsplash
这是关于幂律和厚尾的系列文章中的第二篇。在上一篇文章中,我提供了对幂律的初学者友好介绍,并提出了我们在分析这些幂律时标准统计工具存在的 3 个问题。虽然意识到这些问题可以帮助我们避免它们,但在实际操作中,如何判断某些数据遵循什么分布并不总是清楚。在这篇文章中,我将描述如何客观地从实际数据中检测幂律,并分享一个社会媒体数据的具体例子。
注意:如果你对幂律分布或厚尾等术语不熟悉,请参考本系列的 第一篇文章 作为入门指南。
幂律打破统计学基础
在上一篇文章中,我们重点讨论了两种分布:高斯分布和幂律分布。我们发现这些分布具有截然相反的统计属性。即,幂律由稀有事件驱动,而高斯分布则不是。

示例高斯分布和幂律分布。图片由作者提供
这种稀有事件驱动的特性引发了 3 个问题 ,这些问题影响了我们许多喜欢的统计工具(例如均值、标准差、回归等)在分析幂律时的应用。总结是,如果数据呈现高斯型,可以使用回归和计算期望值等常见方法而无需担忧。然而,如果数据更类似于幂律,这些技术可能会给出不正确和误导性的结果。
我们还看到了第三种(更顽皮的)分布,它可能既类似于高斯分布,又类似于幂律(尽管它们具有相反的特性),称为对数正态分布。

(顽皮的)对数正态分布看起来既类似于高斯分布又类似于幂律。图像由作者提供。
这种模糊性给从业者在决定最佳分析方法时带来了挑战。为了帮助克服这些挑战,确定数据是否适合幂律、对数正态分布或其他类型的分布可能是有利的。
对数-对数方法
一种流行的将幂律拟合到现实世界数据中的方法是我称之为“对数-对数方法”[1]。这个想法来自于对幂律概率密度函数(PDF)取对数,如下所推导。

对幂律概率分布函数取对数[2]。图像由作者提供。
上述推导将幂律的 PDF 定义转化为线性方程,如下图所示。

突出显示对数(PDF)的线性形式。图像由作者提供。
这意味着遵循幂律的数据的直方图将遵循一条直线。在实践中,这看起来是为某些数据生成直方图并将其绘制在对数-对数图上[1]。人们可能会更进一步,进行线性回归以估计分布的α值(这里,α = -m+1)。
然而,这种方法存在显著的局限性。这些局限性在参考文献[1]中进行了描述,并在下文中总结。
-
斜率(因此α)估计会受到系统误差的影响。
-
回归误差可能很难估计。
-
即使分布不遵循幂律,拟合效果也可能看起来很好。
-
拟合可能不符合概率分布的基本条件,例如归一化。
最大似然方法
虽然对数-对数方法简单易行,但其局限性使得它不够理想。相反,我们可以转向更具数学依据的方法,即最大似然,这是一种广泛使用的统计方法,用于推断给定数据的最佳模型参数。
最大似然包含两个关键步骤。步骤 1:获得似然函数。步骤 2:最大化与模型参数相关的似然。
步骤 1:编写似然函数
似然是一种特殊类型的概率。简单来说,它量化了在特定模型下我们的数据的概率。我们可以将其表示为所有观测数据的联合概率 [3]。在 Pareto 分布的情况下,我们可以这样写。

Pareto 分布的似然函数(即一种特殊类型的幂律) [4]。注意:在处理似然函数时,观测值(即 x_i)是固定的,而模型参数是变化的。 作者提供的图像。
为了让最大化似然稍微容易一点,通常使用对数似然(它们由相同的α值最大化)。

对数似然推导 [4]。作者提供的图像。
步骤 2:最大化似然
拥有(对数)似然函数后,我们现在可以将确定最佳参数选择的任务框定为优化问题。为了根据我们的数据找到最佳的α值,这归结为将关于α的l(α)的导数设置为零,然后求解α。以下是推导过程。

最大似然估计器的推导 [4]。作者提供的图像。
这为我们提供了所谓的最大似然估计器来估计α值。有了它,我们可以插入观测值 x 来估计 Pareto 分布的α值。
理论基础确定后,让我们看看这在应用于实际数据(来自我的社交媒体账户)时的效果。
示例代码:拟合社交媒体数据中的幂律
一个存在胖尾数据的领域是社交媒体。例如,一小部分创作者获得了大部分关注,少数 Medium 博客获得了大多数阅读,等等。
在这里,我们将使用powerlaw Python 库来确定来自我各种社交媒体渠道(即 Medium、YouTube、LinkedIn)的数据是否确实遵循幂律分布。这些示例的数据和代码可以在GitHub 存储库中找到。
[## YouTube-Blog/power-laws/2-detecting-powerlaws at main · ShawhinT/YouTube-Blog
代码以补充 YouTube 视频和 Medium 上的博客帖子。 - YouTube-Blog/power-laws/2-detecting-powerlaws at main ·…
人工数据
在将基于最大似然的方法应用于现实世界中的混乱数据之前,让我们看看将该技术应用于分别由 Pareto 和对数正态分布真正生成的人工数据时会发生什么。这将有助于我们在将该方法应用于我们不知道“真实”基础分布类别的数据之前建立期望。
首先,我们导入一些有用的库。
import numpy as np
import matplotlib.pyplot as plt
import powerlaw
import pandas as pd
np.random.seed(0)
接下来,让我们从 Pareto 和对数正态分布中生成数据。
# power law data
a = 2
x_min = 1
n = 1_000
x = np.linspace(0, n, n+1)
s_pareto = (np.random.pareto(a, len(x)) + 1) * x_min
# log normal data
m = 10
s = 1
s_lognormal = np.random.lognormal(m, s, len(x)) * s * np.sqrt(2*np.pi)
为了了解这些数据的样子,绘制直方图是有帮助的。在这里,我绘制了每个样本的原始值直方图和原始值的对数。这种后者的分布使得视觉上更容易区分幂律和对数正态数据。

幂律分布数据的直方图。图片由作者提供。

对数正态分布数据的直方图。图片由作者提供。
从上述直方图可以看出,原始值的分布在这两种分布中看起来 qualitatively 相似。然而,我们可以看到对数分布中的明显差异。即,对数幂律分布高度偏斜且不以均值为中心,而对数对数正态分布则类似于高斯分布。
现在,我们可以使用powerlaw库来拟合每个样本的幂律并估计α和 x_min 的值。这是我们的幂律样本的效果。
# fit power to power law data
results = powerlaw.Fit(s_pareto)
# printing results
print("alpha = " + str(results.power_law.alpha)) # note: powerlaw lib's alpha definition is different than standard i.e. a_powerlawlib = a_standard + 1
print("x_min = " + str(results.power_law.xmin))
print('p = ' + str(compute_power_law_p_val(results)))
# Calculating best minimal value for power law fit
# alpha = 2.9331912195958676
# x_min = 1.2703447024073973
# p = 0.999
拟合在估计真实参数值(即 a=3,x_min=1)方面做得相当不错,如上面打印的 alpha 和 x_min 值所示。上述 p 值量化了拟合的质量。较高的 p 值意味着更好的拟合(有关此值的更多信息,请参见参考文献[1]的第 4.1 节)。
我们可以对对数正态分布做类似的操作。
# fit power to log normal data
results = powerlaw.Fit(s_lognormal)
print("alpha = " + str(results.power_law.alpha)) # note: powerlaw lib's alpha definition is different than standard i.e. a_powerlawlib = a_standard + 1
print("x_min = " + str(results.power_law.xmin))
print('p = ' + str(compute_power_law_p_val(results)))
# Calculating best minimal value for power law fit
# alpha = 2.5508694755027337
# x_min = 76574.4701482522
# p = 0.999
我们可以看到对数正态样本也很好地符合幂律分布(p=0.999)。然而,请注意,x_min 值在尾部较远。虽然这对某些应用场景可能有帮助,但它并没有告诉我们哪个分布最适合样本中的所有数据。
为了克服这个问题,我们可以手动将 x_min 值设置为样本最小值,并重新进行拟合。
# fixing xmin so that fit must include all data
results = powerlaw.Fit(s_lognormal, xmin=np.min(s_lognormal))
print("alpha = " + str(results.power_law.alpha))
print("x_min = " + str(results.power_law.xmin))
# alpha = 1.3087955873576855
# x_min = 2201.318351239509
.Fit()方法还会自动生成对数正态分布的估计值。
print("mu = " + str(results.lognormal.mu))
print("sigma = " + str(results.lognormal.sigma))
# mu = 10.933481999687547
# sigma = 0.9834599169175509
估计的对数正态参数值接近实际值(mu=10,sigma=1),所以拟合再次做得很好!
然而,通过固定 x_min,我们丢失了质量指标 p(由于某种原因,当提供 x_min 时,该方法不会生成值)。所以这引出了一个问题,我应该选择哪个分布参数?幂律还是对数正态?
为了回答这个问题,我们可以通过对数似然比(R)将幂律拟合与其他候选分布进行比较。正值的 R 表示幂律拟合效果更好,而负值的 R 则表示替代分布更好。此外,每次比较都会给出一个显著性值(p)。这在下面的代码块中进行了演示。
distribution_list = ['lognormal', 'exponential', 'truncated_power_law', \
'stretched_exponential', 'lognormal_positive']
for distribution in distribution_list:
R, p = results.distribution_compare('power_law', distribution)
print("power law vs " + distribution +
": R = " + str(np.round(R,3)) +
", p = " + str(np.round(p,3)))
# power law vs lognormal: R = -776.987, p = 0.0
# power law vs exponential: R = -737.24, p = 0.0
# power law vs truncated_power_law: R = -419.958, p = 0.0
# power law vs stretched_exponential: R = -737.289, p = 0.0
# power law vs lognormal_positive: R = -776.987, p = 0.0
如上所示,当包含对数正态样本中的所有数据时,每个替代分布都优于幂律。此外,基于似然比,对数正态分布和对数正态分布(正值)拟合效果最佳。
现实世界的数据
现在我们已经将powerlaw库应用于我们知道真实情况的数据,让我们尝试将其应用于未知基础分布的数据。
我们将遵循类似于之前的过程,但使用现实世界的数据。在这里,我们将分析以下数据:过去一年中我在Medium个人资料上的月度关注者增长、我所有YouTube视频的收益,以及我LinkedIn帖子上的每日印象。
我们将开始绘制直方图。

Medium 关注者增长的直方图。图片由作者提供。

YouTube 视频收益的直方图。图片由作者提供。

LinkedIn 每日印象的直方图。图片由作者提供。
从这些图中有两点引起了我的注意。第一,所有三个图形看起来都更像对数正态分布的直方图,而不是我们之前看到的幂律直方图。第二,Medium 和 YouTube 的分布稀疏,这意味着它们可能没有足够的数据来得出强有力的结论。
接下来,我们将对所有三个分布应用幂律拟合,同时将 x_min 设置为每个样本中的最小值。结果如下所示。

幂律和对数正态分布参数估计的经验数据。图片由作者提供。
为了确定哪种分布最佳,我们可以再次对幂律拟合与一些替代分布进行逐一比较。结果如下所示。

幂律和替代分布的拟合比较。图片由作者提供。
使用显著性阈值 p<0.1,我们可以得出以下结论:Medium 关注者和 LinkedIn 印象最适合对数正态分布,而幂律最能代表 YouTube 收益。
当然,由于 Medium 关注者和 YouTube 收益的数据有限(N<100),我们应该对这些数据得出的任何结论保持谨慎态度。
结论
许多标准统计工具在应用于遵循幂律分布的数据时会出现问题。因此,检测经验数据中的幂律可以帮助实践者避免错误分析和误导性结论。
然而,幂律是更一般现象胖尾的极端情况。在本系列的下一篇文章中,我们将进一步深入研究,并通过四个便捷的启发式方法对任何给定的数据集进行胖尾度量。
👉 更多关于幂律与胖尾: 简介 | 量化胖尾
直觉与示例代码
towardsdatascience.com
资源
社交媒体: YouTube 🎥 | LinkedIn | Twitter
支持: 请我喝咖啡 ☕️
免费获取我写的每个新故事。附言:我不会将你的邮箱分享给任何人。注册后,你将创建一个…
shawhin.medium.com](https://shawhin.medium.com/subscribe?source=post_page-----b464190fade6--------------------------------)
[1] arXiv:0706.1062 [physics.data-an]
[2] arXiv:2001.10488 [stat.OT]
[3] en.wikipedia.org/wiki/Likelihood_function
[4] en.wikipedia.org/wiki/Pareto_distribution
使用自编码器检测信用卡欺诈
原文:
towardsdatascience.com/detection-of-credit-card-fraud-with-an-autoencoder-9275854efd48
实现异常检测器的指南
·发表于 Towards Data Science ·10 分钟阅读·2023 年 6 月 1 日
--

由 Christiann Koepke 提供的照片,来源于 Unsplash
你想知道如何使用 Python 和 TensorFlow 创建一个异常检测器吗?那么这篇文章适合你。信用卡公司使用异常检测器来检测欺诈交易。识别欺诈交易很重要,以便客户不必为他们没有购买的东西付钱。
每天都有大量的信用卡交易,但只有极少数交易是欺诈性的。欺诈交易就是异常。文章展示了一个自编码器模型的实现,用于检测这些欺诈交易。首先,我们定义异常并介绍不同类型的异常。然后我们描述用于信用卡欺诈检测的异常检测器的实现。让我们开始吧!
异常检测概述
异常检测算法识别在获取的数据集中出现的新颖和意外的结构。文献中有很多异常的定义。我们为我们的用例推导了一个定义。
异常定义
Chandola 等人 [1] 将异常描述为数据中不符合正常行为定义的模式。另一个广泛使用的定义来自 Hawkins。Hawkins [2] 将离群点描述为一个偏离其他观测值的程度,以至于怀疑它是由其他机制生成的。关于所呈现的定义,需要注意两个重要方面(参见 [3]):
-
异常的分布与数据的一般分布有很大偏离。
-
大多数数据是正常的观测值,而异常只是其中的一小部分。
我们定义异常如下:
异常是与数据大多数分布显著不同的观察或一系列观察。
异常类型
我们基本上可以区分三种类型的异常。
-
点异常或点异常是指观察值与其他数据显著偏离[3],并且仅持续很短的时间[4]。欺诈交易可能导致点异常。
-
集体异常是指一组观察值相较于其他数据而言是异常的。单个观察值可能表现为异常或正常,只有在群体中出现时才会显得异常[4]。只有在个体观察值相关的数据中,你才能检测到集体异常。
-
上下文异常描述的是在特定上下文中显得异常的观察或多个观察[3]。这些异常在全球范围内考虑时,位于该变量有效值的范围内[4]。
在本文中,我们开发了一个只能检测点异常的自编码器模型。还有更高级的自编码器模型,如 GRU 或 LSTM 自编码器,它们在数据中包含时间组件。
异常检测
异常检测方法的输出有两个选项:
-
异常评分: 观察值与期望值的偏差。
-
二进制标签: 正常或异常观察(标签:0 或 1)。
一些算法直接以二进制标签作为输出,而其他算法则根据异常评分计算标签,超过某个阈值。因此,你可以根据异常评分推导标签。[4]
以下是异常评分的函数(参见 [3]):

函数:异常评分(作者提供的公式)
在方程中,γ 表示异常评分,x_t 是时间 t 的观察值。n 是观察值的数量,p 是变量/特征的数量。你可以通过定义阈值 δ ∈ R 将异常评分转换为二进制标签(正常或异常)。

公式:二进制标签(作者提供的公式)
方程式显示你可以根据阈值 δ 调整二进制标签。本文中的实现使用二进制标签(0:无欺诈,1:欺诈)。
自编码器概念
在本节中,我们探讨自编码器的理论。自编码器是人工神经网络,通常用于异常检测。它们属于半监督方法,因为你只用数据的正常状态来训练它们。自编码器模型试图有效地压缩输入(编码),并最终重建这一压缩(解码),以便重建尽可能接近输入数据。压缩层称为潜在表示。
因此,网络由两个部分组成(参见 [5],第 499 页):
-
编码器函数 z = g(x) 和
-
解码器函数 x′ = f (z)
下图展示了自编码器的一般结构。

自编码器的工作原理(作者提供的图像)
此外,学习一个精确的重建并没有多大用处,因为我们主要想要近似数据中的相关结构。编码中的压缩迫使自编码器学习数据中的有用特征。自编码器使用输入和输出之间的差异作为重建误差。在训练过程中,我们旨在最小化此误差。您可以使用不同的误差度量作为误差函数,例如均方误差 (MSE):

公式:均方误差 (MSE)(公式由作者提供)
公式展示了观察值 x_t 的重建误差 (MSE) 的计算。误差函数的选择和自编码器的结构取决于具体的应用。例如,编码器和解码器可以由简单的前馈层、LSTM/GRU 层或卷积神经网络 (CNN) 层组成。
信用卡欺诈检测实现
我们使用了来自 Kaggle 的 信用卡欺诈检测 数据集(根据 数据库内容许可协议 (DbCL) v1.0 许可)。该数据集包含了 2013 年 9 月来自欧洲信用卡客户的匿名信用卡交易数据。数据使用 PCA 进行了匿名处理。让我们开始数据准备工作。
数据准备
首先,我们读取数据并输出前五个数据点。
df = pd.read_csv('creditcard.csv')
df.head()
我们看到有 28 列匿名数据,而有两列未被匿名化。时间和金额列未被匿名化。还有一个类别列(0: 正常交易或 1: 欺诈交易)。总共有 31 列数据。
接下来,我们检查数据集是否包含缺失值。
df.isnull().sum().max() # Output: 0
该数据集没有缺失值。这很好。
接下来,我们对两个类别(正常交易和欺诈交易)的分布感兴趣。为此,我们计算各自类别在总数据中的百分比份额。
print('No Frauds', round(df['Class'].value_counts()[0]/len(df) * 100,2), '% of the dataset')
print('Frauds', round(df['Class'].value_counts()[1]/len(df) * 100,2), '% of the dataset')
# Output:
# No Frauds 99.83 % of the dataset
# Frauds 0.17 % of the dataset
我们看到欺诈交易的比例很小。在监督学习方法中,数据不平衡是危险的,因为这些方法是基于标签进行学习的。然而,在我们的使用案例中,我们采用了半监督的方法。训练仅使用正常交易的数据。因此,对于自编码器方法来说,不需要对数据进行平衡。
接下来,我们通过对特征 Amount 进行对数转换,将其转换为正态分布的对数等价物。这种转换改善了自动编码器的训练。我们还移除了 Time 特征。我们将数据分为训练数据、验证数据和测试数据。然后,我们从训练数据中移除欺诈交易,因为我们只用正常交易来训练自动编码器。
然后,我们使用来自sklearn 库的MinMaxScaler对数据进行缩放。我们在训练数据上训练缩放器,然后用这个缩放器转换训练、验证和测试数据。重要的是,只在训练数据上调整缩放器,否则验证或测试数据的信息会流入训练中。现在我们的数据已经准备好进行建模了。
建模
在本节中,我们使用 TensorFlow 实现了自动编码器模型。以下清单展示了自动编码器的实现:
class AnomalyDetector(Model):
def __init__(self, n_features):
super(AnomalyDetector, self).__init__()
self.encoder = tf.keras.Sequential([
layers.Dense(16, activation="elu"),
layers.Dense(8, activation="elu"),
layers.Dense(4, activation="elu"),
layers.Dense(2, activation="elu"),
layers.ActivityRegularization(l1=1e-3)])
self.decoder = tf.keras.Sequential([
layers.Dense(2, activation="elu"),
layers.Dense(4, activation="elu"),
layers.Dense(8, activation="elu"),
layers.Dense(16, activation="elu"),
layers.Dense(n_features, activation="elu")])
def call(self, x):
encoded = self.encoder(x)
decoded = self.decoder(encoded)
return decoded
代码展示了编码器和解码器的实现。编码器将数据压缩成两个特征。然后,自动编码器根据这两个特征执行解码。在这种情况下,自动编码器尝试重构输入。自动编码器的目标是尽可能准确地重构输入。我们使用 ELU 作为激活函数,因为它在我们的测试中表现最佳。现在我们可以训练我们的模型了。
autoencoder = AnomalyDetector(n_features)
opt = tf.keras.optimizers.Adam(learning_rate=0.001)
autoencoder.compile(optimizer=opt, loss="mean_squared_error")
earlystopper = EarlyStopping(monitor='val_loss', patience=5, verbose=1, restore_best_weights=True)
history = autoencoder.fit(
normal_train_data_transformed,
normal_train_data_transformed,
epochs=100,
batch_size=128,
validation_data=(normal_val_data_transformed, normal_val_data_transformed),
callbacks=[earlystopper],
shuffle=True)
首先,我们用特征数量初始化我们的模型。然后我们定义我们的优化器并设置学习率。在我们的情况下,我们使用优化器 Adam。最后,我们用优化器和损失函数编译我们的模型。在我们的情况下,我们使用 MSE 作为损失函数。我们还定义了一个早停机制,通过在验证损失在连续五个 epoch 内没有变化时停止训练。
test_data_predictions = autoencoder.predict(X_test_transformed) # predict reconstruction
mse = np.mean(np.power(X_test_transformed - test_data_predictions, 2), axis=1) # calculates MSE between test data and reconstruction
一旦自动编码器经过训练和验证,我们可以在测试数据上尝试模型。自动编码器尝试尽可能准确地重构测试数据的输入。自动编码器能很好地重构正常交易。另一方面,它必须对欺诈交易进行差的重构。我们可以使用 MSE 计算输入和重构之间的误差。欺诈交易会产生较大的 MSE,而正常交易会产生较小的 MSE。
评估
在上一节中,我们训练了我们的模型。现在是时候评估模型了。首先,我们查看均方误差(MSE,重构误差)的分布。

重构损失的分布(图片来自作者)
图表显示,欺诈交易的 MSE(x 轴)高于正常交易。然而,一些欺诈交易的 MSE 与正常交易相似。
在接下来的内容中,您可以看到一些指标:

评估指标自动编码器模型(图片来自作者)
在异常检测中,召回率是一个重要的指标。我们的模型实现了 87%的召回率。这对于异常检测模型来说是一个很好的值。此外,模型的精确度为 72%,这对于这样的模型也很不错。真正的正例率(TPR)为 75.2%,这意味着我们的模型以 75%的准确率检测到欺诈交易。另一方面,我们的模型未能检测到 25%的欺诈交易(假阴性率(FNR))。
进一步优化的目标必须是最小化假阴性率。然而,我们必须记住,我们在训练模型时从未见过欺诈!在这方面,它的表现还算不错。然而,我们仍然可以尝试稍微改进模型。
使用我们的 FNR,我们可以看到网络无法完美地进行泛化。为了提高模型的性能,我们可以使用不同的自编码器模型,例如,每层的神经元数量不同,或者使用三或四个神经元的潜在表示。我们进行了一些测试并变化了神经元的数量。结果,以下模型取得了更好的效果。
class AnomalyDetector(Model):
def __init__(self, n_features):
super(AnomalyDetector, self).__init__()
self.encoder = tf.keras.Sequential([
layers.Dense(32, activation="elu"),
layers.Dense(16, activation="elu"),
layers.Dense(8, activation="elu"),
layers.Dense(4, activation="elu"),
layers.ActivityRegularization(l1=1e-3)])
self.decoder = tf.keras.Sequential([
layers.Dense(4, activation="elu"),
layers.Dense(8, activation="elu"),
layers.Dense(16, activation="elu"),
layers.Dense(32, activation="elu"),
layers.Dense(n_features, activation="relu")])
def call(self, x):
encoded = self.encoder(x)
decoded = self.decoder(encoded)
return decoded
我们将潜在层的神经元增加到四个。编码器的第一层有 32 个神经元,解码器的最后一层有 32 个神经元。输出层使用 ReLU 激活函数。这些变化在测试数据上导致了更好的结果。你可以在下面看到这些结果。

评估指标改进的自编码器(图片由作者提供)
我们通过调整将召回率和精确度提高了 2 个百分点。此外,我们的 TPR 从之前的 75.2%提高到 78.5%。新模型的 FNR 为 21.5%。这意味着检测到的欺诈交易更多。
结果表明,尝试不同的模型配置以获得最佳模型是至关重要的。在我们的用例中,更大的自编码器导致了更好的结果。然而,这并不总是如此。重要的是实施一个适合用例的模型。
实施自编码器模型在测试标签很少时特别有用。自编码器仅需要正常数据而不是异常数据进行训练。所提出的方法特别适用于异常发生较少且仅有极少数标签的用例。
结论
在这篇文章中,我们展示了一个用于信用卡欺诈的异常检测器的实现。首先,我们介绍了异常检测的基础知识,然后是自编码器的直觉。自编码器压缩输入并尽可能重建它。此外,自编码器仅需要正常交易进行训练。然后,我们用 Tensorflow 实现了一个自编码器。评估结果表明,自编码器表现良好。
👉🏽 加入我们的每周免费 Magic AI 通讯,获取最新的 AI 更新!
免费订阅 以便在我们发布新故事时得到通知:
## 订阅我们的邮件,随时获取 Janik 和 Patrick Tinz 发布的内容。
订阅邮件,随时获取 Janik 和 Patrick Tinz 发布的内容。通过注册,你将创建一个 Medium 账户,如果你还没有的话…
了解更多关于我们的信息,请访问我们的 关于页面。不要忘记关注我们 X。非常感谢阅读。如果你喜欢这篇文章,请随意分享。祝你有美好的一天!
使用 我们的链接 注册 Medium 会员,阅读无限制的 Medium 故事。
参考文献
-
[1] Varun Chandola, Arindam Banerjee 和 Vipin Kumar. “异常检测:综述”。发表于:ACM 计算调查(CSUR)41.3 (2009), 第 1–58 页。
-
[2] Douglas M Hawkins. 《离群点识别》。第 11 卷,Springer,1980。
-
[3] Mohammad Braei 和 Sebastian Wagner. “单变量时间序列中的异常检测:前沿综述”。发表于:arXiv 预印本 arXiv:2004.00433 (2020)。
-
[4] Andrew A Cook, Göksel Mısırlı 和 Zhong Fan. “物联网时间序列数据的异常检测:综述”。发表于:IEEE 物联网期刊 7.7 (2019), 第 6481–6494 页。
-
[5] Ian Goodfellow, Yoshua Bengio 和 Aaron Courville. 《深度学习》。MIT 出版社,2016。
确定性与概率性深度学习
原文:
towardsdatascience.com/deterministic-vs-probabilistic-deep-learning-5325769dc758
概率性深度学习
·发表于 数据科学之路 ·9 分钟阅读·2023 年 1 月 11 日
--
介绍
本文属于“概率性深度学习”系列。该系列每周覆盖概率性深度学习的方法。主要目标是扩展深度学习模型以量化不确定性,即了解它们所不知道的内容。
本文涵盖了确定性与概率性深度学习之间的主要区别。确定性深度学习模型旨在优化标量值损失函数,而概率性深度学习模型则旨在优化概率目标函数。确定性模型为每个输入提供一个单一预测,而概率性模型则提供对其预测不确定性的概率性描述,并具备从模型生成新样本的能力。
目前已发布的文章:
-
从头开始的最大似然估计(TensorFlow Probability)
-
从头开始的概率性线性回归(TensorFlow)
-
确定性与概率性深度学习

图 1:今天的座右铭:我们对事物总是充满不确定性;我们的模型为什么应该有所不同? (source)
我们使用 TensorFlow 和 TensorFlow Probability 开发我们的模型。TensorFlow Probability 是一个建立在 TensorFlow 之上的 Python 库。我们将从 TensorFlow Probability 中找到的基本对象开始,并了解如何操作它们。在接下来的几周中,我们将逐步增加复杂性,并将我们的概率模型与现代硬件(例如 GPU)上的深度学习相结合。
和往常一样,代码可以在我的 GitHub 上找到。
确定性与概率深度学习
深度学习已经成为广泛机器学习任务(如图像和语音识别、自然语言处理和强化学习)的主导方法。深度学习的一个关键特性是能够从大量数据中学习复杂的非线性函数。然而,传统的深度学习模型是确定性的,意味着它们在给定相同输入时会做出相同的预测。
确定性深度学习模型,例如前馈神经网络,旨在优化标量值的损失函数,如均方误差或交叉熵。训练后的模型为每个输入生成一个单一的预测,但不提供关于预测不确定性的任何信息。相比之下,概率深度学习模型,如变分自编码器(VAEs)和生成对抗网络(GANs),旨在优化概率目标函数,如数据的负对数似然或近似后验分布。这些模型提供了对其预测不确定性的概率特征。
概率深度学习模型的主要优势之一是能够从模型中生成新样本。例如,VAE 可以用来生成与训练数据相似的新图像,而 GAN 可以用来生成与训练数据不同的新图像。此外,概率深度学习模型通常用于无监督学习,其目标是学习数据的紧凑表示而无需任何标签。
数据与方法
在本文中,我们使用 MNIST 数据集及其被破坏的对应数据集来评估我们的方法。MNIST 数据集的破坏版本通过在原始图像上叠加灰色斑点,增加了额外的复杂性,使手写数字分类更具挑战性。
我们的目标是构建一个卷积神经网络(CNN),有效地将手写数字的图像分类为 10 个不同的类别。为此,我们使用前述数据集,以提供对确定性和概率 CNN 性能的全面评估。
def load_data(name):
data_dir = os.path.join('data', name)
x_train = 1 - np.load(os.path.join(data_dir, 'x_train.npy')) / 255.
x_train = x_train.astype(np.float32)
y_train = np.load(os.path.join(data_dir, 'y_train.npy'))
y_train_oh = tf.keras.utils.to_categorical(y_train)
x_test = 1 - np.load(os.path.join(data_dir, 'x_test.npy')) / 255.
x_test = x_test.astype(np.float32)
y_test = np.load(os.path.join(data_dir, 'y_test.npy'))
y_test_oh = tf.keras.utils.to_categorical(y_test)
return (x_train, y_train, y_train_oh), (x_test, y_test, y_test_oh)
def inspect_images(data, num_images):
fig, ax = plt.subplots(nrows=1, ncols=num_images, figsize=(2*num_images, 2))
for i in range(num_images):
ax[i].imshow(data[i, :, :], cmap='gray')
ax[i].axis('off')
plt.show()
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()
inspect_images(data=x_train, num_images=8)

图 2:来自 MNIST 数据集的随机示例。
def read_prefetch(dataset):
x = []
y = []
for x_, y_ in dataset:
x.append(x_)
y.append(y_)
x = np.asarray(x)
y = np.asarray(y)
return x, y
x_c_train, y_c_train = read_prefetch(xy_c_train)
x_c_test, y_c_test = read_prefetch(xy_c_test)
inspect_images(data=x_c_train, num_images=8)

图 3:来自损坏版本的 MNIST 数据集的随机示例。
确定性架构
我们首先正式介绍确定性模型,它是一个由多个关键架构组件组成的卷积神经网络(CNN)分类器。具体来说,这个模型包括:
-
一个卷积层,其中卷积操作由一组 8 个滤波器进行,滤波器的大小为 5x5,采用‘VALID’填充,输出通过一个修正线性单元(ReLU)激活函数。
-
一个最大池化层,其中在大小为 6x6 的非重叠窗口内取最大值,减少特征图的空间维度。
-
一个 flatten 层,将池化后的特征图压缩成一个单一的向量,使得最终的全连接层可以进行完全连接的计算。
-
一个全连接层,也称为全连接层,有 10 个单元,并应用 softmax 激活函数以获得类别标签的最终概率分布。
这个 CNN 分类器架构旨在高效提取辨别特征并对高维图像数据进行分类。
def get_det_model(input_shape, loss, optimizer, metrics):
model = Sequential([
Conv2D(input_shape=input_shape,
filters=8,
kernel_size=(5,5),
activation='relu',
padding='valid'),
MaxPooling2D(pool_size=(6,6)),
Flatten(),
Dense(10, activation='softmax')
])
model.compile(loss=loss, optimizer=optimizer, metrics=metrics)
return model
tf.random.set_seed(0)
deterministic_model = get_det_model(
input_shape=(28, 28, 1),
loss=SparseCategoricalCrossentropy(),
optimizer=RMSprop(),
metrics=['accuracy']
)
deterministic_model.summary()
_________________________________________________________________
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
conv2d (Conv2D) (None, 24, 24, 8) 208
max_pooling2d (MaxPooling2D (None, 4, 4, 8) 0
)
flatten (Flatten) (None, 128) 0
dense (Dense) (None, 10) 1290
=================================================================
Total params: 1,498
Trainable params: 1,498
Non-trainable params: 0
_________________________________________________________________
我们已经实现了上述讨论的架构,因此现在我们准备开始训练过程。
deterministic_model.fit(x_train, y_train, epochs=5)
Epoch 1/5
1875/1875 [==============================] - 8s 4ms/step - loss: 2.1986 - accuracy: 0.8423
Epoch 2/5
1875/1875 [==============================] - 7s 4ms/step - loss: 0.2248 - accuracy: 0.9470
Epoch 3/5
1875/1875 [==============================] - 7s 4ms/step - loss: 0.1678 - accuracy: 0.9563
Epoch 4/5
1875/1875 [==============================] - 7s 4ms/step - loss: 0.1428 - accuracy: 0.9626
Epoch 5/5
1875/1875 [==============================] - 7s 4ms/step - loss: 0.1336 - accuracy: 0.9641
最后,我们可以在两个数据集上检查准确性。
print('Accuracy on MNIST test set: ',
str(deterministic_model.evaluate(x_test, y_test, verbose=False)[1]))
print('Accuracy on corrupted MNIST test set: ',
str(deterministic_model.evaluate(x_c_test, y_c_test, verbose=False)[1]))
Accuracy on MNIST test set: 0.9659000039100647
Accuracy on corrupted MNIST test set: 0.902400016784668
概率架构
与之前讨论的确定性模型相比,我们的概率模型引入了一种新的输出分布对象的方法,即 One-Hot 分类分布。这使得可以对图像标签的随机不确定性进行建模,从而更全面地表征模型的预测。
One-Hot 分类分布是一种离散概率分布,作用于 one-hot 位向量,其中事件维度等于 K,即类别数量。它在数学上等同于分类分布,分类分布是一种离散概率分布,作用于正整数,其关键区别在于分类分布具有空事件维度,而 One-Hot 分类分布的事件维度等于 K。
我们提出的概率卷积神经网络(CNN)架构包括:
-
一个卷积层,其中卷积操作由一组 8 个滤波器进行,滤波器的大小为 5x5,采用‘VALID’填充,输出通过一个修正线性单元(ReLU)激活函数。
-
一个最大池化层,其中在大小为 6x6 的非重叠窗口内取最大值,减少特征图的空间维度。
-
一个 flatten 层,将池化后的特征图压缩成一个单一的向量,使得最终的全连接层可以进行完全连接的计算。
-
一个密集层,也称为全连接层,具有参数化随后的概率层所需的单元数量。
-
一个 OneHotCategorical 分布层,其事件形状为 10,对应于 10 个类别。
这一新颖的架构,结合了 One-Hot Categorical 输出分布,使得能够对图像标签上的随机不确定性进行建模,并允许对模型预测进行更全面的表征。
def nll(y_true, y_pred):
return -y_pred.log_prob(y_true)
def get_probabilistic_model(input_shape, loss, optimizer, metrics):
model = Sequential([
Conv2D(input_shape=input_shape,
filters=8,
kernel_size=(5,5),
activation='relu',
padding='valid'),
MaxPooling2D(pool_size=(6,6)),
Flatten(),
Dense(10),
tfpl.OneHotCategorical(event_size=10,
convert_to_tensor_fn=tfd.Distribution.mode)
])
model.compile(loss=loss, optimizer=optimizer, metrics=metrics)
return model
tf.random.set_seed(0)
probabilistic_model = get_probabilistic_model(
input_shape=(28, 28, 1),
loss=nll,
optimizer=RMSprop(),
metrics=['accuracy'])
probabilistic_model.summary()
_________________________________________________________________
Model: "sequential_1"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
conv2d_1 (Conv2D) (None, 24, 24, 8) 208
max_pooling2d_1 (MaxPooling (None, 4, 4, 8) 0
2D)
flatten_1 (Flatten) (None, 128) 0
dense_1 (Dense) (None, 10) 1290
one_hot_categorical (OneHot ((None, 10), 0
Categorical) (None, 10))
=================================================================
Total params: 1,498
Trainable params: 1,498
Non-trainable params: 0
_________________________________________________________________
我们的模型实现完成了。请花一点时间将上述实现与我们之前进行的实现(实现相应的确定性架构)进行比较。如果您对我们为概率模型版本定义的特定组件(例如损失函数)有任何疑问,请参考本系列早期的文章。我们现在可以开始训练过程。
probabilistic_model.fit(x_train, tf.keras.utils.to_categorical(y_train), epochs=5)
Epoch 1/5
1875/1875 [==============================] - 9s 5ms/step - loss: 2.0467 - accuracy: 0.8283
Epoch 2/5
1875/1875 [==============================] - 8s 5ms/step - loss: 0.1950 - accuracy: 0.9458
Epoch 3/5
1875/1875 [==============================] - 9s 5ms/step - loss: 0.1545 - accuracy: 0.9574
Epoch 4/5
1875/1875 [==============================] - 8s 4ms/step - loss: 0.1381 - accuracy: 0.9611
Epoch 5/5
1875/1875 [==============================] - 8s 4ms/step - loss: 0.1332 - accuracy: 0.9635
最后,我们可以检查这个版本模型的准确性。请注意,概率模型的测试准确性与确定性模型相同。这是因为两者的模型架构是等效的,唯一的区别是概率模型返回一个分布对象。
print('Accuracy on MNIST test set: ',
str(probabilistic_model.evaluate(x_test, tf.keras.utils.to_categorical(y_test), verbose=False)[1]))
print('Accuracy on corrupted MNIST test set: ',
str(probabilistic_model.evaluate(x_c_test, tf.keras.utils.to_categorical(y_c_test), verbose=False)[1]))
Accuracy on MNIST test set: 0.9641000032424927
Accuracy on corrupted MNIST test set: 0.8906999826431274
结果与讨论
准确率是评估模型性能的重要指标。然而,它有时较为浅显,因为它未提供有关预测不确定性的信息。
在本节中,我们超越预测标签,提供了对预测不确定性的可视化表示。为此,我们对模型的预测分布进行采样,并计算结果样本的百分位数。
def plot_model_prediction(image, true_label, model):
predicted_probabilities = model(image[np.newaxis, :])
fig, (ax1, ax2) = plt.subplots(nrows=1, ncols=2, figsize=(10, 2),
gridspec_kw={'width_ratios': [2, 4]})
# Show the image and the true label
ax1.imshow(image[..., 0], cmap='gray')
ax1.axis('off')
ax1.set_title('True label: {}'.format(str(true_label)))
# Show a 95% prediction interval of model predicted probabilities
probs = np.zeros((10, 10))
for i in range(10):
probs[i] = np.array(np.mean(tf.squeeze(predicted_probabilities.sample(100)).numpy(), axis=0))
pct_2p5 = np.percentile(probs, 2.5, axis=0)
pct_97p5 = np.percentile(probs, 97.5, axis=0)
bar = ax2.bar(np.arange(10), pct_97p5, color='red')
bar[int(true_label)].set_color('green')
ax2.bar(np.arange(10), pct_2p5-0.02, color='white', linewidth=1, edgecolor='white')
ax2.set_xticks(np.arange(10))
ax2.set_ylim([0, 1])
ax2.set_ylabel('Probability')
ax2.set_title('Model estimated probabilities')
plt.show()
模型对第一张图像是 7 的预测非常有信心,这是正确的。对于第二张图像,模型遇到困难,为许多不同类别分配了非零概率。
for i in [0, 18]:
plot_model_prediction(x_test[i], np.squeeze(y_test[i]), probabilistic_model)

图 4:概率模型对 MNIST 数据集的预测。
再次,该模型对第一张图像的预测非常有信心。尽管有些污点,但这个数字仍然容易识别。第二个数字则显著更难识别。模型仍然能够很好地预测正确的数字,并展示了对这个选择的不确定性。
for i in [0, 17]:
plot_model_prediction(x_c_test[i], np.squeeze(y_c_test[i]), probabilistic_model)

图 5:概率模型对 MNIST 数据集损坏版本的预测。
结论
在本文中,我们讨论了确定性和概率深度学习模型之间的关键差异,重点介绍了这些模型在图像分类任务中的应用。通过分析 CNN 在 MNIST 数据集及其损坏版本上的表现,我们展示了概率深度学习模型可以实现与确定性模型相似的准确度水平,并提供了对其预测不确定性的概率描述。
概率深度学习模型的主要优势之一是能够从模型中生成新样本。这在图像合成或数据增强等任务中非常有用,目标是从给定的数据集中创建新的、真实的图像。此外,概率深度学习模型还可以用于无监督学习,其目标是学习数据的紧凑表示而无需任何标签。
保持联系: LinkedIn
参考资料和材料
[1] — Coursera: 深度学习专业化
[2] — Coursera: TensorFlow 2 深度学习 专业化
[3] — TensorFlow 概率指南与教程
[4] — TensorFlow 博客中的 TensorFlow 概率文章
DETR(用于目标检测的变换器)
原文:
towardsdatascience.com/detr-transformers-for-object-detection-a8b3327b737a
对论文“基于变换器的端到端检测”的深入剖析和清晰解释
·发表于Towards Data Science ·阅读时长 8 分钟·2023 年 10 月 7 日
--

Aditya Vyas拍摄,来自Unsplash
注:本文深入探讨了计算机视觉的复杂世界,特别是变换器和注意力机制。建议了解论文“Attention is All You Need.”中的关键概念。
历史快照
DETR,即DEtection TRansformer,开创了一种新的目标检测方式,由Nicolas Carion 及其团队于 2020 年在 Facebook AI Research 提出。
尽管目前尚未达到 SOTA(最先进技术)水平,DETR 对目标检测任务的创新重新定义显著影响了随后的模型,例如 CO-DETR,它是LVIS上目标检测和实例分割的当前最先进技术。
与传统的多对一问题场景不同,其中每个真实值对应多个锚点候选,DETR 通过将目标检测视为集合预测问题,实现了预测与真实值之间的一对一对应,从而消除了对某些后处理技术的需求。
目标检测概述
目标检测是计算机视觉的一个领域,专注于识别和定位图像或视频帧中的对象。除了仅仅对对象进行分类外,它还提供一个边界框,指示对象在图像中的位置,从而使系统能够理解各种识别对象的空间上下文和位置。

Yolo5 视频分割,来源
物体检测本身非常有用,例如在自动驾驶中,但它也是实例分割的前置任务,在实例分割中我们尝试寻找物体的更精确轮廓,同时能够区分不同实例(与语义分割不同)。
揭开非极大值抑制的神秘面纱(并摆脱它)

非极大值抑制,作者提供的图像
非极大值抑制 (NMS) 长期以来一直是物体检测算法中的基石,在后处理过程中发挥了不可或缺的作用,以精细化预测输出。在传统的物体检测框架中,模型提出了大量围绕潜在物体区域的边界框,其中一些不可避免地显示出显著的重叠(如上图所示)。
NMS 通过保留具有最大预测对象性分数的边界框,同时抑制那些表现出高重叠度的相邻框来解决这一问题,重叠度通过交并比 (IoU) 量化。具体来说,给定预设的 IoU 阈值,NMS 迭代选择具有最高置信度分数的边界框,并取消那些 IoU 超过该阈值的边界框,确保每个对象只有一个高置信度的预测。
尽管 DETR(DEtection TRansformer)无处不在,但它大胆地避开了传统的 NMS,通过将物体检测重新定义为集合预测问题来重新发明物体检测。
通过利用变换器,DETR 直接预测固定大小的边界框,从而消除了传统 NMS 的必要性,显著简化了物体检测管道,同时保持甚至提升了模型性能。
深入探讨 DETR 架构
在高层次图像中,DETR 是
-
图像编码器(实际上是一个双重图像编码器,因为首先是一个CNN 骨干网络,然后是一个变换器 编码器,以增加更多的表达能力)
-
变换器解码器 从图像编码中生成边界框。

DETR 架构,图片来自 文章
让我们详细了解每个部分:
- 骨干网络:
我们从一个具有 3 个颜色通道的初始图像开始:
这张图像被输入到一个骨干网络中,该骨干网络是一个卷积神经网络
我们使用的典型值是 C = 2048 和 H = W = H0 = W0 = 32
2. 变换器编码器:
变换器编码器在理论上并非强制要求,但它为骨干网络增加了更多表达能力,消融研究表明性能有所提升。
首先,1x1 卷积将高层激活图 f 的通道维度从 C 减少到较小的维度 d。
卷积之后
但正如你所知,Transformers 将输入向量序列映射到输出向量序列,所以我们需要压缩空间维度:
在压缩空间维度后
现在我们准备将其输入到 Transformer 编码器中。
重要的是要注意Transformer 编码器仅使用自注意力机制。
就是这样图像编码部分!

解码器的更多细节,图像来自文章
3. Transformer 解码器:
这一部分是最难理解的部分,耐心点,如果你理解了这部分,你就理解了文章的大部分内容。
解码器使用自注意力和交叉注意力机制的组合。它接收N个对象查询,每个查询将被转换为一个输出框和类别。
框预测是什么样的?
它实际上由两个组件组成。
-
一个边界框具有一些坐标(x1,y1,x2,y2)来识别边界框。
-
一类(例如海鸥,但也可以为空)
重要的是要注意N是固定的。这意味着 DETR 始终预测N个边界框。但其中一些可能为空。我们只需确保N足够大,以覆盖图像中的足够对象。
然后交叉注意力机制可以关注编码部分(骨干网络 + Transformer 编码器)生成的图像特征。
如果你对机制不确定,这个方案应该能澄清问题:

详细的注意力机制,图像来自文章
在原始 Transformer 架构中,我们生成一个 token,然后使用自注意力和交叉注意力的组合来生成下一个 token 并重复。但在这里,我们不需要这种递归的公式,我们可以一次性生成所有输出,从而利用并行性。
主要创新:二分匹配损失
如前所述,DETR 产生N个输出(框 + 类)。但每个输出只对应一个真实框。如果你记得清楚,这就是关键点,我们不想应用任何后处理来过滤重叠的框。

二分匹配,由作者提供的图像
我们基本上想将每个预测与最近的真实框关联。因此,我们实际上是在寻找预测集与真实框集之间的一个双射,来最小化总损失。
那么我们如何定义这个损失呢?
1. 匹配损失(成对损失)
我们首先需要定义一个匹配损失,它对应于一个预测框和一个真实框之间的损失:
这个损失需要考虑两个组件:
-
分类损失(预测框内的类别是否与真实类别相同)
-
边界框损失(边界框是否接近真实框)

匹配损失
更准确地说,对于边界框组件,有两个子组件:
-
交并比损失(IOU)
-
L1 损失(坐标之间的绝对差异)

2. 双射的总损失
计算总损失时,我们只是对N个实例求和:

所以基本上我们的问题是找到最小化总损失的双射:

问题的重新表述
性能洞察

DETR 与 Faster RCNN 的性能对比
-
DETR: 这指的是原始模型,使用了一个用于目标检测的 transformer 和ResNet-50 作为骨干网络。
-
DETR-R101: 这是 DETR 的一个变体,采用了ResNet-101 骨干网络而不是 ResNet-50。这里,“R101”指的是“ResNet-101”。
-
DETR-DC5: 这个版本的 DETR 使用了修改过的ResNet-50 骨干网络中的扩张 C5 阶段,由于特征分辨率的提高,提高了模型在小物体上的表现。
-
DETR-DC5-R101: 这个变体结合了这两种修改。它使用 ResNet-101 骨干网络,并包括扩张 C5 阶段,受益于更深的网络和更高的特征分辨率。
DETR 在大型物体上显著超越了基线,这很可能是由于 transformer 允许的非局部计算。然而,值得注意的是,DETR 在小物体上的表现较差。
为什么在这种情况下 Attention 机制如此强大?

对重叠实例的 Attention,图像来自文章
非常有趣的是,我们可以观察到在重叠实例的情况下,Attention 机制能够正确地分离出各个实例,如上图所示。

极端点的 Attention 机制
同样有趣的是,注意力机制集中在物体的极端点上以生成边界框,这正是我们所期望的。
总结
DETR 不仅仅是一个模型;它是一个范式转变,将目标检测从一个一对多的问题转变为一个集合预测问题,充分利用了 Transformer 架构的进展。
自其诞生以来,已经出现了许多改进,例如 DETR++和 CO-DETR,现在在LVIS数据集上引领了实例分割和目标检测的最前沿。
感谢阅读!在你离开之前:
- 查看我在 Github 上的AI 教程合集
[## GitHub — FrancoisPorcher/awesome-ai-tutorials: 最好的 AI 教程合集让你成为…]
最好的 AI 教程合集,让你成为数据科学的专家!— GitHub …
你应该将我的文章直接发送到你的邮箱。 在这里订阅。
如果你想访问 Medium 上的优质文章,你只需每月支付 $5 会员费。如果你通过 我的链接注册,你将用你的一部分费用支持我,而无需额外支付。
如果你觉得这篇文章有见解且有帮助,请考虑关注我并点赞,以便获得更多深入的内容!你的支持帮助我继续制作对我们集体理解有帮助的内容。
参考文献
-
“End-to-End Object Detection with Transformers” 由 Nicolas Carion、Francisco Massa、Gabriel Synnaeve、Nicolas Usunier、Alexander Kirillov 和 Sergey Zagoruyko 编写。你可以在 arXiv 上阅读全文。
-
Zong, Z., Song, G., & Liu, Y.(出版年份)。DET 网上协作混合分配训练。
arxiv.org/pdf/2211.12860.pdf。
在 Power BI 中开发和测试 RLS 规则
原文:
towardsdatascience.com/develop-and-test-rls-rules-in-power-bi-9dc705945feb
通常,并非所有用户都应有权限访问报告中的所有数据。在这里,我将解释如何在 Power BI 中开发 RLS 规则以配置访问权限,并如何测试它们。
·发表于 Towards Data Science ·阅读时长 11 分钟·2023 年 6 月 19 日
--

介绍
许多客户希望根据特定规则限制其报告中的数据访问。
数据访问被称为行级安全(简称 RLS)。
你可以在 Medium 上找到许多关于 Power BI 中 RLS 的文章。
我在下面的参考部分添加了其中的两个。
尽管所有文章都很好地解释了基础知识,但我总是缺少关于如何开发更复杂规则以及如何轻松测试它们的解释。
在本文中,我将一步步解释 RLS 的基础知识并逐步增加复杂性。
此外,我还将向你展示如何使用 DAX Studio 构建查询来测试 RLS 规则,然后再将它们添加到数据模型中。
所以,我们开始吧。
场景
我使用的场景是,用户根据公司内的门店或门店的地理位置访问零售销售数据,包括两者的组合。
在 Contoso 数据模型中,我使用以下表:

图 1 — 涉及的表(图由作者提供)
我创建了以下报告来测试我的结果:

图 2 — 起始报告(图由作者提供)
创建简单规则
要创建 RLS 规则,你需要打开安全角色编辑器:

图 3 — 打开安全角色编辑器(图由作者提供)
接下来,你可以创建一个新角色并设置该角色的名称:

图 4 — 创建一个角色并重命名它(图示由作者提供)
在我的情况下,我将名称设置为“StorePermissions”。
现在,我可以开始添加一个表达式来控制对 Store 表的访问:

图 5 — 将 DAX 表达式添加到新角色(图示由作者提供)
我们已经有了一个新的、更简单的 RLS 规则编辑器几个月了。
在我的情况下,我想添加一个 DAX 表达式。所以,我点击“切换到 DAX 编辑器”按钮。
起初,我添加了最简单的表达式:TRUE()。

图 6 — 最简单的 RLS 规则(图示由作者提供)
要理解 RLS 规则,你必须知道访问是由 RLS 规则编辑器中表达式的输出控制的。
如果表达式的输出不是空的或 FALSE(),用户将获得访问权限。
原则上,RLS 规则编辑器中的任何表达式都会作为筛选器添加到任何查询中。
在我更详细地解释之前,让我们先看看这个第一个表达式的效果。
为了测试规则,我保存表达式并关闭编辑器。
现在我可以使用新规则查看报告:

图 7 — 测试 RLS 规则(图示由作者提供)
在报告页面顶部,你会看到一个黄色横幅,显示你正在使用 StorePermission 规则查看报告。
由于 StorePermission 规则不限制访问,你不会看到任何区别。
让我们尝试一些不同的东西。
现在我将 RLS 规则中的表达式更改为 FALSE()。
当我测试规则时,我不会看到任何数据:

图 8 — 使用 FALSE() 测试规则(图示由作者提供)
这证明如果表达式不返回 FALSE(),数据是可以访问的。
测试查询
为了详细了解这种效果,让我展示一个 DAX 查询,以在没有任何限制的情况下获取结果:
EVALUATE
SUMMARIZECOLUMNS(
Store[Store]
,"Retail_Sales", 'All Measures'[Retail Sales]
)
ORDER BY Store[Store]
当我添加一个带有 TRUE() 的 RLS 规则,如上所示,查询变成类似于以下的查询:
EVALUATE
FILTER(
SUMMARIZECOLUMNS(
Store[Store]
,"Retail_Sales", 'All Measures'[Retail Sales]
)
,TRUE()
)
ORDER BY Store[Store]
我将查询封装在一个 FILTER() 函数中,并添加了 TRUE() 作为筛选表达式。
在接下来的示例中,我将使用 CALCULATETABLE(),因为编写代码更高效灵活。
稍后会详细介绍这一点。
使其更复杂
接下来,我想限制对所有包含“Contoso T”字符串的门店的访问。
为此,我将规则编辑器中的表达式更改为以下内容:
CONTAINSSTRING('Store'[Store], "Contoso T")
测试规则时,我得到了以下结果:

图 9 — 限制访问“Contoso T”门店的结果(图示由作者提供)
使用 DAX 查询测试规则
测试这种规则的结果将是很好的。
在这种情况下,我使用以下 DAX Studio 查询来检查结果:
EVALUATE
CALCULATETABLE(
SUMMARIZECOLUMNS(
Store[Store]
,"Retail_Sales", 'All Measures'[Retail Sales]
)
CONTAINSSTRING('Store'[Store], "Contoso T") = TRUE()
)
ORDER BY Store[Store]
内部部分,使用 SUMMARIZECOLUMNS(),生成输出表。
在这种情况下,我只对门店列表感兴趣。
然后,我用 CALCULATETABLE() 将 SUMMARIZECOLUMNS() 包装起来,以向查询添加过滤器。
在这种情况下,我添加了来自 RLS 规则的表达式,包括一个“= TRUE()”检查。
结果如下:

图 10 — 检查查询的结果(作者提供的图)
那么在后台发生了什么呢?
让我们看看存储引擎查询:

图 11 — 检查查询的结果(作者提供的图)
那么,当我将 RLS 规则应用到这个查询时会发生什么呢?
我可以通过 DAX Studio 轻松应用 RLS 规则:

图 12 — 激活 RLS 规则(作者提供的图)
存储引擎查询如下:

图 13 — 带有 RLS 规则的查询分析
第一个查询(第 2 行)检索所有商店的列表。
第二个查询在 WHERE 子句中包含 RLS 规则。
结果不是匹配的商店列表(根据过滤器),而是包含 RLS 规则的神秘行。
你可以看到存储引擎(SE)查询的结果仍然包含 309 行,如上所述,这些行的数量是所有商店 + 3 行。
我们有 3 行差异的提示在 SE 查询下方的文本中:估算大小:行数 = 309
实际返回的行数可能确实是 306。
但这个分析表明 RLS 规则是在存储引擎之后应用的,因为查询结果仅包含 21 行:所有以“Contoso T”开头的商店。
这很重要,因为计算最终结果的公式引擎(FE)在存储引擎之后是单线程的,只能使用一个 CPU 核心。
而 SE 是多线程的,可以使用多个 CPU 核心。
因此,我们必须避免在 RLS 规则中编写低效的代码。
组合规则
接下来,我想组合两个表达式:
-
仅包括以“Contoso T”开头的商店
-
仅包括欧洲的商店
为了实现这一点,我使用简单编辑器向地理表中添加第二个表达式:

图 14 — 向地理表添加表达式(作者提供的图)
当我切换到 DAX 编辑器时,我得到以下表达式:

图 15 — 来自简单编辑器的 DAX 表达式(作者提供的图)
注意使用了严格等于运算符。
更改为简单等于运算符可能是必要的。
测试规则时的结果是:

图 16 — 组合规则的结果(作者提供的图)
这个规则的 DAX 查询将如下所示:

图 17 — 转换为 DAX 查询及结果(作者提供的图)
现在,让我们给 RLS 规则增加另一层复杂性:
我想限制访问那些:
-
商店的名称以“Contoso T”开头,并且位于欧洲
或
-
商店的名称以“Contoso S”开头,并且位于北美
这次,我从 DAX 查询开始。这是开发和测试表达式的更简单方法。
我将第一个查询用过滤表达式括起来。
由于我需要过滤两个表(Store 和 Geography),我必须使用 FILTER() 和 RELATED():
EVALUATE
CALCULATETABLE(
ADDCOLUMNS(
SUMMARIZECOLUMNS(Store[Store], 'Geography'[Continent])
,"Retail_Sales", 'All Measures'[Retail Sales]
)
,FILTER(Store
,OR(CONTAINSSTRING('Store'[Store], "Contoso T") && RELATED(Geography[Continent]) = "Europe"
,CONTAINSSTRING('Store'[Store], "Contoso S") && RELATED(Geography[Continent]) = "North America")
)
)
ORDER BY [Retail Sales] DESC, 'Geography'[Continent], Store[Store]
我需要 RELATED() 函数,因为我使用 FILTER() 遍历 Store 表,并且需要 Geography 表中的 Continent 列。
由于 Geography 表在关系的一侧,我可以使用 RELATED() 获取 Continent 列。
这是结果:

图 18 — 组合规则的查询(作者绘图)
接下来,我们必须将此过滤器转换为 RLS 规则。
对于 RLS 规则,我们可以移除 FILTER() 函数,因为 RLS 规则本身作为过滤器工作。

图 19 — 转换为一个 RLS 规则(作者绘图)
请注意,我从“Geography”表中移除了表达式。
当我在 Power BI 中测试此规则时,得到的结果与 DAX 查询的结果一致:

图 20 — 测试组合 RLS 规则(作者绘图)
为了测试 RLS 规则,例如,当你只想获取过滤后的商店列表时,你可以写一个简单的查询,仅使用 FILTER() 函数:

图 21 — 仅执行 FILTER()(作者绘图)
基于用户登录配置访问
到现在为止,我们查看了静态 RLS 规则。
但大多数情况下,我们需要基于用户登录的规则。
为了实现这一点,我们需要一个表来映射用户与用户需要访问的行。
例如,像这样的表:

图 22 — 分配地理位置的用户列表(作者绘图)
在将表添加到数据模型后,我们需要在新表和“Geography”表之间添加一个关系:

图 23 — 扩展的数据模型(作者绘图)
新的“Geography Access”表和“Geography”表之间的关系必须正确配置。
添加关系后,Power BI 将其配置为 1:n 关系,其中“Geography”表在一侧,过滤器从“Geography”表流向“Geography Access”。
但我们希望根据“Geography Access”上的 RLS 规则(过滤器)来过滤“Geography”表。
因此,我们必须将交叉过滤方向更改为双向:

图 24 — 关系设置(作者绘图)
此外,我们必须设置“在两个方向上应用安全筛选器”标志,因为 Power BI 在应用 RLS 规则时会忽略交叉筛选方向设置。
现在我们可以添加 RLS 规则:

图 25 — 配置 RLS 规则(作者提供的图)
记得在添加此规则之前,移除 Store 表上的任何筛选表达式。
测试 RLS 规则时,我得到了这个结果:

图 26 — 空结果(作者提供的图)
为了了解发生了什么,让我们回到 RLS 规则编辑器并将规则视图更改为 DAX:

图 27 — 错误的 RLS 规则(作者提供的图)
简单的 RLS 规则编辑器无法识别 DAX 函数,并将其作为文本添加到筛选器中。
我们必须将表达式更改为如下:

图 28 — 正确的 DAX 规则(作者提供的图)
现在结果如预期:

图 29 — 使用我的用户和正确的 RLS 表达式测试 RLS 规则(作者提供的图)
报告页面左上角的卡片包含一个带有 USERPRINCIPALNAME() 函数的度量,以确保在测试期间正确的用户处于活动状态。
我甚至可以使用另一个用户测试 RLS 规则:

图 30 — 使用另一个用户测试 RLS 规则(作者提供的图)
有趣的是,这个用户不需要实际存在。它只需要包含在“地理位置访问”列表中。
这是测试结果:

图 31 — 使用测试用户的测试结果(作者提供的图)
在顶部的黄色线中,你可以看到测试期间的活动用户。
结论
我向你展示了如何创建基础 RLS 规则以及如何测试它们。
然后我增加了更多的复杂性,并分析了 RLS 规则对底层存储引擎的影响。
我们已经看到公式引擎处理了部分 RLS 规则。因此,我们必须在 RLS 规则中编写高效的代码。
在将 RLS 规则实施到数据模型之前,了解如何测试它们非常重要。
通过理解规则如何应用于数据模型,可以更容易地理解错误结果。
最后,我向模型中添加了动态基于用户的 RLS 规则。
在 DAX 查询中测试这些规则更加困难,因为你必须知道每个用户可以访问哪些数据,以编写正确的测试查询来验证结果。
我希望我能给你一些关于简化使用 Power BI 中 RLS 功能的提示。

图片由 Andrew George 提供,来源于 Unsplash
参考文献
你可以在这篇文章中找到 Power BI 中的安全功能列表:
在我关于这个主题的第一篇文章发布一年后,这里是关于 Power BI 中新安全功能的更新
[towardsdatascience.com
你可以在 Power BI(现在是 Fabric)社区页面找到关于 Power BI 中行级安全的简单解释:Row-level security (RLS) with Power BI — Power BI | Microsoft Learn。
我推荐这篇由 Nikola Ilic 撰写的文章,在其中你可以找到关于 RLS 的起点:
“谁在报告中看到了什么?” 是 Power BI 中的关键安全问题之一。学习两种可能的实现方法…
[towardsdatascience.com
另一个关于 Power BI 中行级安全的良好入门文章由 Elias Nordlinder 撰写:
[## 如何在 Power BI 中实施行级安全(第 I 部分)
行级安全(Row-Level Security)是一种根据不同角色对数据进行不同筛选的方法。这可能是静态实现的…
访问我的故事列表以获取更多信息 关于 FILTER() 函数 以及如何 用 DAX Studio 分析 DAX 查询。
我使用了 Contoso 示例数据集,像我之前的文章中一样。你可以从微软 这里 免费下载 ContosoRetailDW 数据集。
Contoso 数据可以根据 MIT 许可证自由使用,详细信息请见 这里。
[## 订阅以便每次 Salvatore Cagliari 发布新文章时收到电子邮件。
每当Salvatore Cagliari发布内容时,你将收到一封电子邮件。通过注册,你将创建一个 Medium 账户,如果你还没有的话…
🦜🔗 LangChain:开发由语言模型驱动的应用程序
原文:
towardsdatascience.com/develop-applications-powered-by-language-models-with-langchain-d2f7a1d1ad1a

图片由 Choong Deng Xiang 提供,来源于 Unsplash
开始使用 LangChain 和 Python 利用 LLM
·发布在 Towards Data Science ·阅读时间 12 分钟·2023 年 4 月 26 日
--
介绍
LangChain 是一个框架,使得利用大型语言模型(如 GPT-3)快速而轻松地开发应用程序成为可能。
然而,这个框架引入了额外的可能性,例如,轻松使用外部数据源,如维基百科,以增强模型提供的能力。我相信你们可能都尝试过使用 Chat-GPT,并发现它无法回答某个日期之后发生的事件。在这种情况下,维基百科的搜索可以帮助 GPT 回答更多问题。
LangChain 结构
该框架分为六个模块,每个模块允许你管理与 LLM 交互的不同方面。让我们看看这些模块是什么。
-
模型:允许你实例化和使用不同的模型。
-
提示:提示是我们与模型互动以尝试获得输出的方式。现在知道如何编写有效的提示至关重要。这个框架模块允许我们更好地管理提示。例如,通过创建可以重用的模板。
-
索引:最好的模型通常是那些与一些文本数据相结合的模型,以便为模型添加上下文或解释某些内容。这个模块帮助我们做到这一点。
-
链:很多时候,单次调用 LLM API 是不够的。该模块允许整合其他工具。例如,一次调用可以是一个复合链,其目的是从维基百科获取信息,然后将这些信息作为输入提供给模型。这个模块允许将多个工具串联起来,以解决复杂的任务。
-
记忆:该模块允许我们在模型调用之间创建持久状态。能够使用一个记住过去所说内容的模型,无疑会提高我们的应用程序的效果。
-
代理:代理是一个 LLM,它做出决策、采取行动、对自己所做的事情做出观察,并以这种方式继续,直到完成任务。该模块提供了一组可以使用的代理。
现在让我们更详细地了解一下如何通过利用不同的模块来实现代码。
模型
模型 允许使用三种不同类型的语言模型,它们是:
-
大型语言模型(LLMs): 这些是能够理解自然语言的基础机器学习模型。这些模型接受字符串作为输入,并生成字符串作为输出。
-
聊天模型: 这些模型由 LLM 提供支持,但专门用于与用户聊天。你可以在这里阅读更多信息。
-
文本嵌入模型: 这些模型用于将文本数据投影到几何空间中。这些模型将文本作为输入,并返回一个数字列表,即文本的嵌入。

Open AI API 密钥
让我们开始使用这个模块。首先,我们需要安装并导入库。要使用这个库,你需要一个来自 Open AI 网站的 API 密钥。
!pip install langchain >> null
!pip install openai >> null
from langchain.llms import OpenAI
#past you api key here
import os
os.environ['OPENAI_API_KEY'] = "yuor-openai-key"
现在我们准备实例化一个 LLM 模型。
llm = OpenAI(model_name="text-ada-001", n=2, best_of=2)
llm("tell me a story please.")
我的输出: 一个年轻的女人去了一个她从未听说过的狂欢派对。她是那里唯一一个在黑暗中带有光亮的人,也是唯一一个能够看到人们美好的一面的人。她喝酒、跳舞,做了所有可能的事情来让这一切发生。在几个小时的舞蹈和饮酒之后,她认识了派对上的一个人。他是一个有些吸引人的男人,留着胡须和山羊胡。她说:“嗨,我是那里唯一一个在黑暗中带有光亮的人。”他说:“嗨,我是那里唯一一个在黑暗中带有光亮的人。”
使用 generate() 方法,你还可以输入一个列表并接收多个答案,我们来看看怎么做。
llm_result = llm.generate(["Tell me a short story", "whath is your favourite colour?", "Is the earth flat?"])

llm.generate 输出
你还可以提取有关大型语言模型结果的一些额外信息。
llm_result.llm_output
我的输出: {‘token_usage’: {‘completion_tokens’: 527, ‘total_tokens’: 544, ‘prompt_tokens’: 17}, ‘model_name’: ‘text-ada-001’}
LLMs 无法理解过长的输入文本。特别是,包含太多标记的文本(如果你不知道标记是什么,可以考虑单词的音节)。在将字符串传递给模型之前,你可以使用简单的方法估计标记的数量。
为了做到这一点,你需要安装tiktoken库。
!pip install tiktoken >> None
import tiktoken
llm.get_num_tokens("How many old are you?")
#OUTPUT : 6
提示
提示是编程 NLP 模型的新方式。然而,创建一个好的提示并非易事。以不同的方式提问可能会导致不同的结果,这些结果可能更准确也可能不准确。提示也可以根据你所面对的使用案例而有所不同。让我们看看这个模块如何帮助我们创建一个好的提示。
提示模板
正如名称所示,提示模板允许我们创建可以重复使用的模板,以便向我们的模型提出问题。模板将包含变量,这些变量是用户会不时更改的唯一内容,以使提示适应其特定的使用案例。
现在让我们看看它们如何被使用。
from langchain import PromptTemplate
template = """
I want you to act as businessman.
You have few passions in your life which are:
- Money
- Data
- Basketball
I want to write a Medium Blog post about {product}.
What is a good for a title of such post?
"""
prompt = PromptTemplate(
input_variables=["product"],
template=template,
)
现在我们可以用我们想要的任何字符串替换提示中的变量‘product’。这样我们就可以根据自己的需要自定义提示。
prompt.format(product = "how to make a bunch of money")
我的输出: 我希望你充当商人。你生活中有几个热情所在:- 钱- 数据- 篮球。我想写一篇关于如何赚一大笔钱的 Medium 博客文章。这样的文章标题应该是什么?
已经有一些模板被编写好,你可以直接导入它们。要了解这些模板是什么,你可以查看文档。我们现在来尝试导入一个。
from langchain.prompts import load_prompt
prompt = load_prompt("lc://prompts/conversation/prompt.json")
prompt
我的输出: PromptTemplate(input_variables=[‘history’, ‘input’], output_parser=None, partial_variables={}, template=’以下是人类和 AI 之间的友好对话。AI 很健谈,并从其上下文中提供了许多具体细节。如果 AI 不知道问题的答案,它会如实地说不知道。\n\n 当前对话:\n{history}\n 人类:{input}\nAI:’, template_format=’f-string’, validate_template=True)
在这个模板中,我们有多个可以填写的变量。其中之一是history。我们需要历史记录来告诉模板一些先前发生的事情,以便它有更多的上下文。如果我们想从这个模板中请求模型的输出,这非常简单。
llm(prompt.format(history="", input="What is 3 - 3?"))
少量示例
有时候我们想向机器学习模型询问特别棘手的事情。此时,获得更准确回答的一种方法是向模型展示正确答案的类似示例,然后提出我们的问题。LangChain 提供了一种保存专门用于保存这些示例的模板的方法。让我们看看如何做到这一点。
首先,让我们创建一些示例。如果我想创建单词的最高级:tall -> tallest。
from langchain import PromptTemplate, FewShotPromptTemplate
# First, create the list of few shot examples.
examples = [
{"word": "cool", "superlative": "coolest"},
{"word": "tall", "superlative": "tallest"},
]
现在我们创建模板,如之前所做的那样,包含两个变量,一个用于基本词,一个用于最高级词。
# Next, we specify the template to format the examples we have provided.
# We use the `PromptTemplate` class for this.
example_formatter_template = """
Word: {word}
Superlative: {superlative}\n
"""
example_prompt = PromptTemplate(
input_variables=["word", "superlative"],
template=example_formatter_template,
)
现在我们将一切结合起来,使用 FewShotPromptTemplate 类,它接受示例、模板、前缀(通常是我们希望给模板的指令)和一个后缀(即模板输出的形式)作为输入。
# Finally, we create the `FewShotPromptTemplate` object.
few_shot_prompt = FewShotPromptTemplate(
examples=examples,
example_prompt=example_prompt,
prefix="Give the superlative of every input",
suffix="Word: {input}\nSuperlative:",
input_variables=["input"],
example_separator="\n\n",
)
print(few_shot_prompt.format(input="big"))
我的输出: 给出每个输入单词的最高级:cool 最高级:coolest 单词:tall 最高级:tallest 单词:big 最高级:
现在你可以输入模型并获得实际输出。
llm(few_shot_prompt.format(input="large"))
索引
这个模块允许我们与外部文档交互,我们想要将其提供给模型。这个模块基于 Retriever 的概念。实际上,我们最常做的就是去获取最能回答我们查询的文档。因此,这是一个信息检索系统。让我们看看 Retriever 接口的样子,以便更好地理解它。(对于那些还不知道的人,接口是一个不能实例化的类,如果你想了解更多,你可以阅读我关于设计模式的文章。)
from abc import ABC, abstractmethod
from typing import List
from langchain.schema import Document
class BaseRetriever(ABC):
@abstractmethod
def get_relevant_documents(self, query: str) -> List[Document]:
"""Get texts relevant for a query.
Args:
query: string to find relevant texts for
Returns:
List of relevant documents
"""
get_relevant_documents方法非常简单,你只需了解如何阅读英语即可理解它的作用。字符串可以根据你的喜好进行更改,所以如果你想修改或实现一个自定义的 Retriever,也没有什么复杂的。
现在我们来看一个实际的例子。我们想要创建一个关于特定文档的问答应用程序。也就是说,模型需要能够回答我关于特定文档的问题。
我们首先需要安装Chroma,它允许我们与Vectorstores一起工作,我们稍后会看到它的用途。
!pip install chromadb >> null
让我们导入一些我们需要的类。
from langchain.chains import RetrievalQA
from langchain.llms import OpenAI
现在让我们从网络上下载一个 txt 文档。
#download data
import requests
url = "https://raw.githubusercontent.com/hwchase17/langchain/master/docs/modules/state_of_the_union.txt"
response = requests.get(url)
data = response.text
with open("state_of_the_union.txt", "w") as text_file:
text_file.write(data)
让我们使用 TextLoader 加载文档。
from langchain.document_loaders import TextLoader
loader = TextLoader('state_of_the_union.txt', encoding='utf8')
Retriever 总是依赖于所谓的Vectorstore检索器。因此,我们可以实例化一个Vectorstore retriever并将我们的文本加载器传递给它。
index = VectorstoreIndexCreator().from_loaders([loader])
现在我们终于有了索引,我们可以对数据提问了。
query = "What did Ohio Senator Sherrod Brown say?"
index.query(query)
我的输出: 俄亥俄州参议员谢罗德·布朗说,“是时候埋葬‘铁锈带’这个标签了。”
链
链允许我们创建更复杂的应用程序。仅仅使用 LLM 通常是不够的,我们想要做得更多。例如,我们首先创建一个模板,然后将编译好的模板作为输入提供给 LLM,这可以通过链简单实现。对我来说,可以将链想象成你在 scikit-learn 中使用的 Pipeline。
LLMChain 就是一个这样的链,它接受输入,将其格式化到模板中,然后将其作为输入传递给模板。
首先,让我们创建一个简单的模板。
from langchain.prompts import PromptTemplate
from langchain.llms import OpenAI
llm = OpenAI(temperature=0.9)
prompt = PromptTemplate(
input_variables=["product"],
template="What is a good name for a website that sells {product}?",
)
现在我们通过指定要使用的模板和模型来创建一个链。
from langchain.chains import LLMChain
chain = LLMChain(llm=llm, prompt=prompt)
#run the chain with the input needed for the prompt
print(chain.run("paints"))
你也可以创建一个自定义链,但我会写一篇更详细的文章来讲解。
内存
每次我们与模型互动时,它都会给我们一个答案,这个答案不会依赖于上下文,因为它不会记住过去的事件。这就像每次我们都是在与一个新的模型交谈一样。在应用程序中,我们通常希望模型具备某种记忆,以便它通过根据之前我们说的话不断改进来学习如何回复我们,特别是当我们在开发聊天机器人时。这就是这个模块的用途。
内存可以通过多种方式实现。例如,我们可以将之前的 N 条消息作为一个字符串或字符串序列提供给模型。现在让我们看看我们可以实现的最简单类型的内存,称为缓冲区。
我们可以使用一个 ChatMessageHistory 类,它允许我们轻松保存所有发送给模型的消息。
from langchain.memory import ChatMessageHistory
history = ChatMessageHistory()history.add_user_message("hello friend!")history.add_ai_message("how are you?")
现在我们可以轻松检索消息。
history.messages
现在我们理解了这个概念,我们可以使用我们刚刚使用的类的包装器,称为 ConversationBufferMemory,它允许我们实际使用消息历史记录。
from langchain.memory import ConversationBufferMemory
memory = ConversationBufferMemory()
memory.chat_memory.add_user_message("hello friend!")
memory.chat_memory.add_ai_message("how are you?")
memory.load_memory_variables({})
最后,我们来看看如何在对话链中使用这个功能。
所以让我们开始与模型进行对话。
from langchain.llms import OpenAI
from langchain.chains import ConversationChain
llm = OpenAI(temperature=0)
conversation = ConversationChain(
llm=llm,
verbose=True,
memory=ConversationBufferMemory()
)
conversation.predict(input="Hello friend!")

conversation.predict(input="I would like to discuss about the universe")

conversation.predict(input="Whats your favourite planet?")

你可以通过访问内存来检索旧消息。
conversation.memory
现在你可以保存你的消息,以便在你想从某个特定点开始对话时重新加载它们并提供给模型。
代理
我们看到的那种链条,遵循像流水线一样的预定步骤。但是我们通常不知道模型回答特定问题时需要采取哪些步骤,因为这也取决于用户不时给出的回答。
我们可以让模型使用各种工具来改进其回答。一个常见的例子是首先去维基百科上阅读一些信息,然后回答一个特定的问题。
from langchain.agents import load_tools
from langchain.agents import initialize_agent
from langchain.agents import AgentType
from langchain.llms import OpenAI
我们将使用两个特定的工具,SERPAPI,它允许模型进行浏览器搜索并获取信息,以及 llm-math,以提高其数学技能。
要安装 SERPAPI,你必须在网站上注册并复制 API Token。
这是网站:serpapi.com/
完成后,我们安装库并将令牌设置为环境变量。
!pip install google-search-results >> null
os.environ['SERPAPI_API_KEY'] = "your token here"
现在我们已经准备好使用它所需的工具来实例化我们的代理了。
因此,我们需要 3 样东西:
-
LLM:我们想要使用的大型语言模型
-
工具:我们希望用来改进基本 LLM 的工具
-
代理:处理 LLM 与工具之间的互动
llm = OpenAI(model_name="text-ada-001")
tools = load_tools(["serpapi", "llm-math"], llm=llm)
agent = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, verbose=True)
现在我们可以向模型提问,依靠它将使用各种可用的工具来回答我们。
agent.run("How old is Joe Biden? What is his current age raised to the 0.56 power?")
结论
在这篇文章中,我们介绍了 LangChain 及其各种模块。每个模块都对提高大型语言模型的能力有用,并且对于基于这些模型开发应用程序至关重要。请注意,因为我在本文中使用的模型不是最好的,所以回答可能不是最优的,而且你得到的回答可能与我的差异很大。不过,目的是为了熟悉这个库。我很期待看到未来基于新大型语言模型开发的所有应用程序。关注我,阅读我即将发布的关于这些话题的深入文章! 😉
结束
Marcello Politi
开发你的第一个 AI 代理:深度 Q 学习
深入人工智能世界——从零开始构建深度强化学习环境。
·
关注 发布于 Towards Data Science ·61 分钟阅读·2023 年 12 月 15 日
--
构建你自己的深度强化学习环境——图片作者
目录
如果你已经掌握了强化学习和深度 Q 学习的概念,可以直接跳到逐步教程。在那里,你将获得所有构建深度强化学习环境所需的资源和代码,包括环境、代理和训练协议。
简介
为什么选择强化学习?
你将获得的内容
什么是强化学习?
深度 Q 学习
逐步教程
1. 初步设置
2. 大致概况
3. 环境:初步基础
4. 实现代理:神经架构和策略
5. 影响环境:完成
6. 从经验中学习:经验重放
7. 定义代理的学习过程:调整神经网络
8. 执行训练循环:将一切整合
9. 总结
10. 附录:优化状态表示
为什么选择强化学习?
最近,像 ChatGPT、Bard、Midjourney、Stable Diffusion 等先进 AI 系统的广泛采用,引发了对人工智能、机器学习和神经网络领域的兴趣,但由于实施这些系统的技术性特质,这种兴趣往往未能得到满足。
对于那些希望开始人工智能之旅(或继续当前进程)的人来说,使用深度 Q 学习构建一个强化学习 gym 是一个很好的起点,因为它不需要高级知识来实现,可以轻松扩展以解决复杂问题,并且可以立即直观地理解人工智能如何变得“智能”。
你将获得的知识
假设你对 Python 有基本了解,在这次深度强化学习的介绍结束时,不使用高级强化学习框架,你将开发自己的 gym,以训练代理解决一个简单问题——从起点移动到目标!
虽然不太光鲜,但你将亲身体验到构建环境、定义奖励结构和基本神经架构、调整环境参数以观察不同学习行为,以及在决策中找到探索与利用之间平衡等主题。
然后你将拥有所有必要的工具来实现自己更复杂的环境和系统,并为深入探讨神经网络和强化学习中的高级优化策略做好充分准备。

图片由作者使用Gymnasium的 LunarLander-v2 环境制作
你还将获得有效利用预构建工具如OpenAI Gym的信心和理解,因为系统的每个组件都是从头开始实现并解密的。这使得你能够将这些强大的资源无缝集成到自己的 AI 项目中。
什么是强化学习?
强化学习(RL)是机器学习(ML)的一个子领域,专注于代理(做出决策的实体)如何在环境中采取行动以完成目标。
其实现包括:
-
游戏
-
自动驾驶车辆
-
机器人技术
-
金融(算法交易)
-
自然语言处理
-
以及更多内容..
强化学习的理念基于行为心理学的基本原则,其中动物或人类从其行为的结果中学习。如果某个行动导致了良好的结果,则代理会获得奖励;如果没有,则会受到惩罚或不给予奖励。
在继续之前,了解一些常用术语非常重要:
-
环境:这是世界——代理操作的地方。它设定了代理必须遵循的规则、边界和奖励。
-
代理:环境中的决策者。代理根据对所处状态的理解来采取行动。
-
状态:代理在环境中的当前情况的详细快照,包括用于决策的相关度量或感官信息。
-
行动:代理与环境交互的具体措施,如移动、收集物品或发起互动。
-
奖励:环境根据代理的行为给予的反馈,可以是正面的、负面的或中性的,引导学习过程。
-
状态/行动空间:代理可能遇到的所有可能状态和它在环境中可以采取的所有行动的组合。这定义了代理必须学习导航的决策和情况的范围。
本质上,在程序的每一步(回合),代理从环境中接收一个状态,选择一个行动,获得奖励或惩罚,环境被更新或回合结束。每一步后收到的信息会被保存为“经验”以供后续训练使用。
举个更具体的例子,假设你在下棋。棋盘是环境,你是代理。每一步(或回合)你查看棋盘的状态,并从行动空间中选择,即所有可能的移动,然后挑选未来奖励最高的行动。完成移动后,你评估这个行动是否良好,并学习以便下次表现得更好。
这可能一开始看起来信息量很大,但随着你自己逐步建立,这些术语会变得非常自然。
深度 Q 学习
Q 学习是一种用于机器学习的算法,其中“Q”代表“质量”,即代理可以采取的行动的价值。它通过创建一个 Q 值表来工作,该表包含行动及其相关的质量,用于估算在给定状态下采取某个行动的预期未来奖励。
代理会获得环境的状态,检查表格以查看是否以前遇到过,然后选择奖励值最高的行动。

Q 学习的顺序流程:从状态评估到奖励和 Q 表更新。—— 作者提供的图像
然而,Q-Learning 有一些缺点。每个状态和动作对必须被探索才能获得良好的结果。如果状态和动作空间(所有可能状态和动作的集合)过大,那么将它们全部存储在表中是不现实的。
这就是深度 Q-Learning(DQL)的作用,它是 Q-Learning 的一种进化形式。DQL 利用深度神经网络(NN)来近似 Q 值函数,而不是将其保存到表中。这使得处理具有高维状态空间的环境成为可能,比如来自相机的图像输入,这对于传统的 Q-Learning 来说是不切实际的。

深度 Q-Learning 是 Q-Learning 和深度神经网络的交集 — 作者提供的图像
神经网络可以在类似的状态和动作上进行泛化,即使它没有在具体情况上进行过训练,也能选择出合适的动作,从而消除对大型表格的需求。
神经网络如何做到这一点超出了本教程的范围。幸运的是,实施深度 Q-Learning 并不需要深刻的理解。
构建强化学习 Gym
1. 初始设置
在开始编写我们的 AI 代理之前,建议您对 Python 中的面向对象编程(OOP)原则有扎实的理解。
如果您尚未安装 Python,以下是 Bhargav Bachina 提供的简单教程,可以帮助您入门。我将使用的版本是 3.11.6。
初学者指南,适合任何想要开始学习 Python 的人
您唯一需要的依赖是 TensorFlow,这是 Google 提供的开源机器学习库,我们将用来构建和训练我们的神经网络。可以通过终端中的 pip 安装。我的版本是 2.14.0。
pip install tensorflow
或者如果这样做不行:
pip3 install tensorflow
您还需要 NumPy 包,但这应该已经包含在 TensorFlow 中。如果遇到问题,可以使用 pip install numpy。
还建议您为每个类创建一个新文件(例如,environment.py)。这样可以避免被信息量淹没,并简化故障排除。
供您参考,这里是包含完整代码的 GitHub 仓库:github.com/HestonCV/rl-gym-from-scratch。请随意克隆、浏览,并将其作为参考!
2. 全局视角
为了真正理解这些概念,而不仅仅是复制代码,了解我们将要构建的不同部分及其如何结合起来至关重要。这样,每个部分都能在更大的图景中找到位置。
以下是一个包含 5000 个回合的训练循环的代码。一个回合本质上是代理与环境之间的一个完整的互动过程,从开始到结束。
这一点在目前不需要实现或完全理解。当我们构建每一部分时,如果你想了解特定类或方法的使用方式,请回到这里。
from environment import Environment
from agent import Agent
from experience_replay import ExperienceReplay
import time
if __name__ == '__main__':
grid_size = 5
environment = Environment(grid_size=grid_size, render_on=True)
agent = Agent(grid_size=grid_size, epsilon=1, epsilon_decay=0.998, epsilon_end=0.01)
# agent.load(f'models/model_{grid_size}.h5')
experience_replay = ExperienceReplay(capacity=10000, batch_size=32)
# Number of episodes to run before training stops
episodes = 5000
# Max number of steps in each episode
max_steps = 200
for episode in range(episodes):
# Get the initial state of the environment and set done to False
state = environment.reset()
# Loop until the episode finishes
for step in range(max_steps):
print('Episode:', episode)
print('Step:', step)
print('Epsilon:', agent.epsilon)
# Get the action choice from the agents policy
action = agent.get_action(state)
# Take a step in the environment and save the experience
reward, next_state, done = environment.step(action)
experience_replay.add_experience(state, action, reward, next_state, done)
# If the experience replay has enough memory to provide a sample, train the agent
if experience_replay.can_provide_sample():
experiences = experience_replay.sample_batch()
agent.learn(experiences)
# Set the state to the next_state
state = next_state
if done:
break
# time.sleep(0.5)
agent.save(f'models/model_{grid_size}.h5')
每个内循环被视为一步。

通过代理-环境互动进行的训练过程——图片由作者提供
在每一步:
-
状态从环境中获取。
-
代理根据这个状态选择一个动作。
-
环境受到操作,返回奖励,采取动作后的结果状态,以及回合是否结束。
-
初始的
state、action、reward、next_state和done随后被保存到experience_replay中,作为一种长期记忆(经验)。 -
然后,代理在这些经验的随机样本上进行训练。
在每个回合结束时,或者按你的需要,模型权重会被保存到模型文件夹中。这些权重可以在后续加载,以避免每次都从头训练。然后,环境在下一个回合开始时被重置。
这个基本结构几乎足以创建一个智能代理来解决各种问题!
正如引言中所述,我们对代理的问题相当简单:从网格中的初始位置到达指定的目标位置。
3. 环境:初步基础
开发这个系统的最明显起点是环境。
要拥有一个功能齐全的 RL 训练环境,环境需要做几件事:
-
维护世界的当前状态。
-
跟踪目标和代理。
-
允许代理对世界进行修改。
-
返回模型可以理解的状态形式。
-
以我们能够理解的方式进行渲染,以观察代理。
这里将是代理度过其整个生命周期的地方。我们将环境定义为一个简单的方阵/二维数组,或在 Python 中的列表列表。
该环境将具有离散的状态空间,这意味着代理可能遇到的状态是不同且可计数的。每个状态都是环境中的一个单独、特定的条件或场景,不同于连续状态空间,其中状态可以以无限、流动的方式变化——想象一下国际象棋与控制汽车。
DQL 专门设计用于离散动作空间(有限数量的动作)——这将是我们关注的重点。其他方法用于连续动作空间。
在网格中,空白区域将由 0 表示,智能体将由 1 表示,目标将由 -1 表示。环境的大小可以是您希望的任何大小,但随着环境的增大,所有可能状态的集合(状态空间)会呈指数增长。这可能会显著延长训练时间。
渲染后的网格将类似于以下内容:
[0, 1, 0, 0, 0]
[0, 0, 0, 0, 0]
[0, 0, 0, 0, 0]
[0, 0, 0, -1, 0]
[0, 0, 0, 0, 0]
构造 **Environment** 类和 **reset** 方法 我们将首先实现 Environment 类以及初始化环境的方法。目前,它将接受一个整数grid_size,但我们很快会扩展这一点。
import numpy as np
class Environment:
def __init__(self, grid_size):
self.grid_size = grid_size
self.grid = []
def reset(self):
# Initialize the empty grid as a 2d list of 0s
self.grid = np.zeros((self.grid_size, self.grid_size))
当创建一个新实例时,Environment 会保存 grid_size 并初始化一个空网格。
reset 方法使用 np.zeros((self.grid_size, self.grid_size)) 填充网格,该方法接受一个形状的元组,并输出一个由零组成的二维 NumPy 数组。
NumPy 数组是一种类似网格的数据结构,行为类似于 Python 中的列表,但它使我们能够高效地存储和操作数值数据。它允许矢量化操作,这意味着操作会自动应用于数组中的所有元素,而无需显式循环。
这使得对大型数据集的计算比标准的 Python 列表要快得多且更高效。不仅如此,它还是我们的智能体神经网络架构所期望的数据结构!
为什么叫做 reset?嗯,这个方法将被调用以重置环境,并最终返回网格的初始状态。
添加智能体和目标
接下来,我们将构造将智能体和目标添加到网格中的方法。
import random
def add_agent(self):
# Choose a random location
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Agent is represented by a 1
self.grid[location[0]][location[1]] = 1
return location
def add_goal(self):
# Choose a random location
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Get a random location until it is not occupied
while self.grid[location[0]][location[1]] == 1:
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Goal is represented by a -1
self.grid[location[0]][location[1]] = -1
return location
智能体和目标的位置将由元组 (x, y) 表示。这两个方法都会在网格边界内选择随机值并返回位置。主要区别在于,add_goal 确保不会选择已被智能体占据的位置。
我们将智能体和目标放置在随机起始位置,以在每个回合中引入变化,这有助于智能体从不同的起点学习如何在环境中导航,而不是记住一条路径。
最后,我们将添加一个方法来在控制台中渲染世界,以便我们能够看到智能体与环境之间的互动。
def render(self):
# Convert to a list of ints to improve formatting
grid = self.grid.astype(int).tolist()
for row in grid:
print(row)
print('') # To add some space between renders for each step
render 做三件事:将 self.grid 的元素转换为整数类型,将其转换为 Python 列表,并打印每一行。
我们不直接打印 NumPy 数组的每一行的唯一原因就是这样做的效果不够美观。
把一切结合起来..
import numpy as np
import random
class Environment:
def __init__(self, grid_size):
self.grid_size = grid_size
self.grid = []
def reset(self):
# Initialize the empty grid as a 2d array of 0s
self.grid = np.zeros((self.grid_size, self.grid_size))
def add_agent(self):
# Choose a random location
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Agent is represented by a 1
self.grid[location[0]][location[1]] = 1
return location
def add_goal(self):
# Choose a random location
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Get a random location until it is not occupied
while self.grid[location[0]][location[1]] == 1:
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Goal is represented by a -1
self.grid[location[0]][location[1]] = -1
return location
def render(self):
# Convert to a list of ints to improve formatting
grid = self.grid.astype(int).tolist()
for row in grid:
print(row)
print('') # To add some space between renders for each step
# Test Environment
env = Environment(5)
env.reset()
agent_location = env.add_agent()
goal_location = env.add_goal()
env.render()
print(f'Agent Location: {agent_location}')
print(f'Goal Location: {goal_location}')
>>>
[0, 0, 0, 0, 0]
[0, 0, -1, 0, 0]
[0, 0, 0, 0, 0]
[0, 0, 0, 1, 0]
[0, 0, 0, 0, 0]
Agent Location: (3, 3)
Goal Location: (1, 2)
在查看位置时,可能会感觉有些错误,但它们应该从左上角到右下角读取为(行,列)。另外,记住坐标是从零开始索引的。
好的,那么环境已经定义好了。接下来是什么呢?
扩展 **reset**
让我们编辑reset方法以处理代理和目标的放置。顺便说一下,也让我们自动化渲染。
class Environment:
def __init__(self, grid_size, render_on=False):
self.grid_size = grid_size
self.grid = []
# Make sure to add the new attributes
self.render_on = render_on
self.agent_location = None
self.goal_location = None
def reset(self):
# Initialize the empty grid as a 2d array of 0s
self.grid = np.zeros((self.grid_size, self.grid_size))
# Add the agent and the goal to the grid
self.agent_location = self.add_agent()
self.goal_location = self.add_goal()
if self.render_on:
self.render()
现在,当调用reset时,代理和目标会被添加到网格中,它们的初始位置会被保存,如果render_on设置为 true,它将渲染网格。
...
# Test Environment
env = Environment(5, render_on=True)
env.reset()
# Now to access agent and goal location you can use Environment's attributes
print(f'Agent Location: {env.agent_location}')
print(f'Goal Location: {env.goal_location}')
>>>
[0, 0, 0, 0, 0]
[0, 0, 0, 0, 0]
[0, 0, 0, 0, 0]
[0, 0, 0, 0, -1]
[1, 0, 0, 0, 0]
Agent Location: (4, 0)
Goal Location: (3, 4)
定义环境的状态
我们现在将实现的最后一个方法是get_state。乍一看,状态可能仅仅是网格本身,但这种方法的问题在于这并不是神经网络所期望的。
神经网络通常需要一维输入,而不是当前网格所表示的二维形状。我们可以通过使用 NumPy 的内置flatten方法将网格展平来解决这个问题。这将把每一行放入同一个数组中。
def get_state(self):
# Flatten the grid from 2d to 1d
state = self.grid.flatten()
return state
这将转换为:
[0, 0, 0, 0, 0]
[0, 0, 0, 1, 0]
[0, 0, 0, 0, 0]
[0, 0, 0, 0, -1]
[0, 0, 0, 0, 0]
转换为:
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0]
正如你所看到的,哪一个单元格是哪个并不是一目了然,但这对深度神经网络来说不会是问题。
现在我们可以更新reset以在grid填充之后返回状态。其他内容将保持不变。
def reset(self):
...
# Return the initial state of the grid
return self.get_state()
到目前为止的完整代码..
import random
class Environment:
def __init__(self, grid_size, render_on=False):
self.grid_size = grid_size
self.grid = []
self.render_on = render_on
self.agent_location = None
self.goal_location = None
def reset(self):
# Initialize the empty grid as a 2d array of 0s
self.grid = np.zeros((self.grid_size, self.grid_size))
# Add the agent and the goal to the grid
self.agent_location = self.add_agent()
self.goal_location = self.add_goal()
if self.render_on:
self.render()
# Return the initial state of the grid
return self.get_state()
def add_agent(self):
# Choose a random location
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Agent is represented by a 1
self.grid[location[0]][location[1]] = 1
return location
def add_goal(self):
# Choose a random location
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Get a random location until it is not occupied
while self.grid[location[0]][location[1]] == 1:
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Goal is represented by a -1
self.grid[location[0]][location[1]] = -1
return location
def render(self):
# Convert to a list of ints to improve formatting
grid = self.grid.astype(int).tolist()
for row in grid:
print(row)
print('') # To add some space between renders for each step
def get_state(self):
# Flatten the grid from 2d to 1d
state = self.grid.flatten()
return state
你现在已经成功实现了环境的基础!虽然,如果你没有注意到,我们还不能与其互动。代理被卡在了原地。
我们将在Agent类编写完成后回到这个问题,以提供更好的上下文。
4. 实现代理神经网络架构和策略
如前所述,代理是接收其环境状态的实体,在这种情况下是世界网格的平面版本,并根据动作空间做出采取何种动作的决定。
需要重申的是,动作空间是所有可能动作的集合,在这种情况下,代理可以向上、向下、向左和向右移动,因此动作空间的大小为 4。
状态空间是所有可能状态的集合。根据环境和代理的视角,这可能是一个巨大的数字。在我们的例子中,如果世界是一个 5x5 的网格,则有 600 个可能的状态;但如果世界是一个 25x25 的网格,则有 390,000 个状态,这会大大增加训练时间。
为了让代理有效地学习完成目标,它需要一些条件:
-
神经网络用于在 DQL 的情况下近似 Q 值(对一个动作的未来奖励的估计总量)。
-
策略或策略是代理选择动作时遵循的规则。
-
环境中的奖励信号告诉代理它的表现如何。
-
能够基于过去的经验进行训练。
可以实现两种不同的策略:
-
贪婪策略:选择当前状态下 Q 值最高的动作。
-
Epsilon-Greedy 策略:选择当前状态下 Q 值最高的动作,但有一个小的概率,即 epsilon(通常表示为ϵ),选择一个随机动作。如果 epsilon = 0.02,那么这个动作有 2%的概率是随机的。
我们将实现Epsilon-Greedy 策略。
为什么随机动作有助于代理学习?探索。
当代理开始时,它可能学习到一条次优路径,并继续选择这条路径而不改变或学习新路径。
从一个较大的 epsilon 值开始,并逐渐减少它,可以让代理在更新 Q 值之前彻底 探索 环境,然后再 利用 学到的策略。我们随着时间减少 epsilon 的量称为 epsilon 衰减,稍后会更清楚。
就像我们对环境做的那样,我们将用一个类来表示代理。
现在,在实现策略之前,我们需要一种获取 Q 值的方法。这时我们代理的大脑——或神经网络——就派上用场了。
神经网络
在这里不扯太远,神经网络只是一个巨大的函数。值进入后,传递到每一层并进行转换,最后输出一些不同的值。仅此而已。真正的魔力在于训练开始时。
这个想法是给神经网络大量标记的数据,比如,“这是一个输入,应该输出什么”。它在每一步训练中慢慢调整神经元之间的值,试图尽可能接近给定的输出,发现数据中的模式,并希望帮助我们预测网络从未见过的输入。

状态通过神经网络转化为 Q 值 — 作者图片
代理类和定义神经网络结构 目前我们将使用 TensorFlow 定义神经网络结构,并专注于数据的“前向传播”。
from tensorflow.keras.layers import Dense
from tensorflow.keras.models import Sequential
class Agent:
def __init__(self, grid_size):
self.grid_size = grid_size
self.model = self.build_model()
def build_model(self):
# Create a sequential model with 3 layers
model = Sequential([
# Input layer expects a flattened grid, hence the input shape is grid_size squared
Dense(128, activation='relu', input_shape=(self.grid_size**2,)),
Dense(64, activation='relu'),
# Output layer with 4 units for the possible actions (up, down, left, right)
Dense(4, activation='linear')
])
model.compile(optimizer='adam', loss='mse')
return model
再说一次,如果你对神经网络不太熟悉,不要被这一部分困扰。虽然我们在模型中使用了 ‘relu’ 和 ‘linear’ 等激活函数,但对激活函数的详细探讨超出了本文的范围。
你需要知道的只是模型将状态作为输入,值在模型的每一层中被转换,四个对应于每个动作的 Q 值被输出。
在构建代理的神经网络时,我们从一个输入层开始,该层处理网格的状态,以 grid_size² 大小的一维数组表示。这是因为我们已经将网格展平以简化输入。该层本身就是我们的输入,因此在架构中无需定义,因为它不接受任何输入。
接下来,我们有两个隐藏层。这些是我们看不到的值,但随着模型的学习,它们对于更接近 Q 值函数的近似非常重要:
-
第一个隐藏层有 128 个神经元,
Dense(128, activation='relu'),并以展平的网格作为输入。 -
第二个隐藏层包含 64 个神经元,
Dense(64, activation='relu'),进一步处理信息。
最后,输出层 Dense(4, activation='linear') 包含 4 个神经元,对应于四种可能的动作(上、下、左、右)。该层输出 Q 值——每个动作未来奖励的估计。
通常,你需要解决的问题越复杂,你需要的隐藏层和神经元就越多。对于我们的简单用例,两个隐藏层应该足够了。
神经元和层可以并且应该进行实验,以找到速度和结果之间的平衡——每一层都增加了网络捕捉和学习数据细微差别的能力。像状态空间一样,神经网络越大,训练就越慢。
贪婪策略 使用这个神经网络,我们现在可以得到一个 Q 值预测,虽然还不是很理想,但已经可以做出决策了。
import numpy as np
def get_action(self, state):
# Add an extra dimension to the state to create a batch with one instance
state = np.expand_dims(state, axis=0)
# Use the model to predict the Q-values (action values) for the given state
q_values = self.model.predict(state, verbose=0)
# Select and return the action with the highest Q-value
action = np.argmax(q_values[0]) # Take the action from the first (and only) entry
return action
TensorFlow 神经网络架构要求输入状态为批量数据。这在你有大量输入并希望获得完整批次的输出时非常有用,但当你只有一个输入需要预测时可能会有些混淆。
state = np.expand_dims(state, axis=0)
我们可以通过使用 NumPy 的 expand_dims 方法并指定 axis=0 来解决这个问题。这会简单地将其转换为一个单一输入的批量。例如,一个 5x5 网格的状态:
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0]
变为:
[[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0]]
在训练模型时,你通常会使用 32 或更多大小的批量。它看起来像这样:
[[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
...
[0, 0, 0, 0, 0, 0, 0, 0, 1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]
现在我们已经以正确的格式准备好了模型的输入,我们可以预测每个动作的 Q 值并选择最高的一个。
...
# Use the model to predict the Q-values (action values) for the given state
q_values = self.model.predict(state, verbose=0)
# Select and return the action with the highest Q-value
action = np.argmax(q_values[0]) # Take the action from the first (and only) entry
...
我们只需将状态传递给模型,它就会输出一批预测。记住,因为我们提供给网络的是一个批量的单一数据,它将返回一个批量的单一数据。此外,verbose=0 确保在每次调用 predict 函数时控制台不会出现常规调试消息。
最后,我们使用 np.argmax 在批量中的第一个且唯一的条目上选择并返回具有最高值的动作的索引。
在我们的例子中,索引 0、1、2 和 3 将分别映射到上、下、左和右。
贪婪策略总是选择根据当前 Q 值具有最高奖励的动作,但这可能不会总是导致最佳的长期结果。
Epsilon-贪婪策略 我们已经实现了贪婪策略,但我们想要的是 Epsilon-贪婪策略。这将随机性引入代理的选择中,以便 探索 状态空间。
重申一下,epsilon 是选择随机动作的概率。我们还希望有一种方法随着代理的学习逐渐降低这一概率,以便 利用 所学策略。如前所述,这称为 epsilon 衰减。
epsilon 衰减值应设置为小于 1 的十进制数,用于在代理每一步之后逐渐减少 epsilon 值。
通常,epsilon 会从 1 开始,而 epsilon 衰减值将接近 1,比如 0.998。在训练过程中的每一步,你将 epsilon 乘以 epsilon 衰减值。
为了说明这一点,下面是 epsilon 在训练过程中的变化情况。
Initialize Values:
epsilon = 1
epsilon_decay = 0.998
-----------------
Step 1:
epsilon = 1
epsilon = 1 * 0.998 = 0.998
-----------------
Step 2:
epsilon = 0.998
epsilon = 0.998 * 0.998 = 0.996
-----------------
Step 3:
epsilon = 0.996
epsilon = 0.996 * 0.998 = 0.994
-----------------
Step 4:
epsilon = 0.994
epsilon = 0.994 * 0.998 = 0.992
-----------------
...
-----------------
Step 1000:
epsilon = 1 * (0.998)¹⁰⁰⁰ = 0.135
-----------------
...and so on
正如你所看到的,epsilon 随着每一步慢慢接近零。到第 1000 步时,随机动作被选择的概率为 13.5%。epsilon 衰减是一个需要根据状态空间进行调整的值。状态空间较大时,可能需要更多探索或更高的 epsilon 衰减。

epsilon 在步骤中的衰减 — 图片由作者提供
即使代理已经训练得很好,保持一个较小的 epsilon 值也是有益的。我们应该定义一个停止点,在该点 epsilon 不再降低,即 epsilon 结束。根据用例和任务的复杂性,这可以是 0.1、0.01,甚至 0.001。
在上图中,你会注意到 epsilon 在 0.1 时停止减少,这是预定义的 epsilon 结束值。
让我们更新我们的 Agent 类以包含 epsilon。
import numpy as np
class Agent:
def __init__(self, grid_size, epsilon=1, epsilon_decay=0.998, epsilon_end=0.01):
self.grid_size = grid_size
self.epsilon = epsilon
self.epsilon_decay = epsilon_decay
self.epsilon_end = epsilon_end
...
...
def get_action(self, state):
# rand() returns a random value between 0 and 1
if np.random.rand() <= self.epsilon:
# Exploration: random action
action = np.random.randint(0, 4)
else:
# Add an extra dimension to the state to create a batch with one instance
state = np.expand_dims(state, axis=0)
# Use the model to predict the Q-values (action values) for the given state
q_values = self.model.predict(state, verbose=0)
# Select and return the action with the highest Q-value
action = np.argmax(q_values[0]) # Take the action from the first (and only) entry
# Decay the epsilon value to reduce the exploration over time
if self.epsilon > self.epsilon_end:
self.epsilon *= self.epsilon_decay
return action
我们将epsilon、epsilon_decay和epsilon_end的默认值分别设为 1、0.998 和 0.01。
记住 epsilon 及其相关值是超参数,用于控制学习过程。它们可以并且应该被实验以达到最佳结果。
方法get_action已更新以包含 epsilon。如果np.random.rand生成的随机值小于或等于 epsilon,则选择一个随机动作。否则,过程与之前相同。
最后,如果epsilon没有达到epsilon_end,我们通过将其乘以epsilon_decay来更新它,如self.epsilon *= self.epsilon_decay。
**代理** 到目前为止:
from tensorflow.keras.layers import Dense
from tensorflow.keras.models import Sequential
import numpy as np
class Agent:
def __init__(self, grid_size, epsilon=1, epsilon_decay=0.998, epsilon_end=0.01):
self.grid_size = grid_size
self.epsilon = epsilon
self.epsilon_decay = epsilon_decay
self.epsilon_end = epsilon_end
self.model = self.build_model()
def build_model(self):
# Create a sequential model with 3 layers
model = Sequential([
# Input layer expects a flattened grid, hence the input shape is grid_size squared
Dense(128, activation='relu', input_shape=(self.grid_size**2,)),
Dense(64, activation='relu'),
# Output layer with 4 units for the possible actions (up, down, left, right)
Dense(4, activation='linear')
])
model.compile(optimizer='adam', loss='mse')
return model
def get_action(self, state):
# rand() returns a random value between 0 and 1
if np.random.rand() <= self.epsilon:
# Exploration: random action
action = np.random.randint(0, 4)
else:
# Add an extra dimension to the state to create a batch with one instance
state = np.expand_dims(state, axis=0)
# Use the model to predict the Q-values (action values) for the given state
q_values = self.model.predict(state, verbose=0)
# Select and return the action with the highest Q-value
action = np.argmax(q_values[0]) # Take the action from the first (and only) entry
# Decay the epsilon value to reduce the exploration over time
if self.epsilon > self.epsilon_end:
self.epsilon *= self.epsilon_decay
return action
我们已经有效地实现了 Epsilon-Greedy 策略,我们几乎准备好让代理开始学习了!
5. 影响环境:完成
环境目前有重置网格、添加代理和目标、提供当前状态以及将网格打印到控制台的方法。
为了使环境完整,我们需要不仅允许代理影响环境,还需要以奖励的形式提供反馈。
定义奖励结构:制定一个好的奖励结构是强化学习的主要挑战。你的问题可能完全在模型的能力范围内,但如果奖励结构设置不正确,模型可能永远无法学习。
奖励的目标是鼓励特定的行为。在我们的例子中,我们希望引导代理到达由-1 定义的目标单元。
类似于网络中的层和神经元,以及 epsilon 及其相关值,定义奖励结构也有许多正确(和错误)的方法。
奖励结构的两种主要类型:
-
稀疏:当奖励仅在少数状态中给予时。
-
密集:当奖励在状态空间中很常见时。
对于稀疏奖励,代理几乎没有反馈来指导它。这就像是每一步都给一个固定的惩罚,如果代理到达目标则提供一个大奖励。
代理确实可以学习达到目标,但根据状态空间的大小,这可能需要更长的时间,并且可能会陷入次优策略。
这与稠密奖励结构相对,稠密奖励结构允许代理更快地训练并表现得更可预测。
稠密奖励结构要么
-
有多个目标。
-
在整个过程中提供提示。
代理有更多的机会学习期望的行为。
例如,假设你在训练一个代理使用身体行走,而你给予的唯一奖励是它达到一个目标。代理可能会通过缓慢移动或在地面上滚动来学习如何到达那里,或者甚至根本没有学习到。
相反,如果你奖励代理朝目标前进、保持站立、迈出一步并保持直立,你将获得更自然和有趣的步态,同时改善学习效果。
允许代理对环境产生影响 为了获得奖励,你必须允许代理与其环境互动。让我们重新审视一下Environment类,以定义这种互动。
...
def move_agent(self, action):
# Map agent action to the correct movement
moves = {
0: (-1, 0), # Up
1: (1, 0), # Down
2: (0, -1), # Left
3: (0, 1) # Right
}
previous_location = self.agent_location
# Determine the new location after applying the action
move = moves[action]
new_location = (previous_location[0] + move[0], previous_location[1] + move[1])
# Check for a valid move
if self.is_valid_location(new_location):
# Remove agent from old location
self.grid[previous_location[0]][previous_location[1]] = 0
# Add agent to new location
self.grid[new_location[0]][new_location[1]] = 1
# Update agent's location
self.agent_location = new_location
def is_valid_location(self, location):
# Check if the location is within the boundaries of the grid
if (0 <= location[0] < self.grid_size) and (0 <= location[1] < self.grid_size):
return True
else:
return False
上述代码首先定义了与每个动作值相关的坐标变化。如果选择动作 0,则坐标变化为(-1, 0)。
记住,在这种情况下,坐标被解释为(行,列)。如果行减少 1,则代理上移一个单元格;如果列减少 1,则代理左移一个单元格。
然后根据移动计算新位置。如果新位置有效,则更新agent_location。否则,agent_location保持不变。
此外,is_valid_location 只是检查新位置是否在网格边界内。
这相当简单,但我们还缺少什么?反馈!
提供反馈 环境需要提供适当的奖励,并确定一集是否完成。
让我们首先加入done标志以指示一集是否结束。
...
def move_agent(self, action):
...
done = False # The episode is not done by default
# Check for a valid move
if self.is_valid_location(new_location):
# Remove agent from old location
self.grid[previous_location[0]][previous_location[1]] = 0
# Add agent to new location
self.grid[new_location[0]][new_location[1]] = 1
# Update agent's location
self.agent_location = new_location
# Check if the new location is the reward location
if self.agent_location == self.goal_location:
# Episode is complete
done = True
return done
...
我们将done默认设置为 false。如果新的agent_location与goal_location相同,则将done设置为 true。最后,我们返回这个值。
我们已经为奖励结构做好了准备。首先,我将展示稀疏奖励结构的实现。这对于大约 5x5 的网格是足够的,但我们将更新它以适应更大的环境。
稀疏奖励 实现稀疏奖励非常简单。我们主要需要在到达目标时给予奖励。
我们还可以为每一步未到达目标的情况给予小的负奖励,并为撞击边界的情况给予更大的奖励。这将鼓励我们的代理优先选择最短路径。
...
def move_agent(self, action):
...
done = False # The episode is not done by default
reward = 0 # Initialize reward
# Check for a valid move
if self.is_valid_location(new_location):
# Remove agent from old location
self.grid[previous_location[0]][previous_location[1]] = 0
# Add agent to new location
self.grid[new_location[0]][new_location[1]] = 1
# Update agent's location
self.agent_location = new_location
# Check if the new location is the reward location
if self.agent_location == self.goal_location:
# Reward for getting the goal
reward = 100
# Episode is complete
done = True
else:
# Small punishment for valid move that did not get the goal
reward = -1
else:
# Slightly larger punishment for an invalid move
reward = -3
return reward, done
...
确保初始化reward以便在 if 块之后可以访问。此外,仔细检查每种情况:有效移动和达成目标、有效移动和未达成目标、以及无效移动。
稠密奖励 实施稠密奖励系统仍然相当简单,只是需要更频繁地提供反馈。
让代理逐步朝目标移动的好方法是什么?
第一个方法是返回曼哈顿距离的负值。曼哈顿距离是行方向的距离加上列方向的距离,而不是直线距离。以下是代码示例:
reward = -(np.abs(self.goal_location[0] - new_location[0]) + \
np.abs(self.goal_location[1] - new_location[1]))
所以,行方向的步数加上列方向的步数,并取其负值。
另一种方法是根据代理移动的方向提供奖励:如果它远离目标,则提供负奖励;如果它朝目标移动,则提供正奖励。
我们可以通过将新的曼哈顿距离从之前的曼哈顿距离中减去来计算。这将是 1 或-1,因为代理每步只能移动一个单元格。
在我们的情况下,选择第二个选项最为合适。这应该提供更好的结果,因为它基于每一步提供即时反馈,而不是更一般的奖励。
这个选项的代码:
...
def move_agent(self, action):
...
if self.agent_location == self.goal_location:
...
else:
# Calculate the distance before the move
previous_distance = np.abs(self.goal_location[0] - previous_location[0]) + \
np.abs(self.goal_location[1] - previous_location[1])
# Calculate the distance after the move
new_distance = np.abs(self.goal_location[0] - new_location[0]) + \
np.abs(self.goal_location[1] - new_location[1])
# If new_location is closer to the goal, reward = 1, if further, reward = -1
reward = (previous_distance - new_distance)
...
如你所见,如果代理没有达到目标,我们计算previous_distance、new_distance,然后将reward定义为这两者的差值。
根据表现情况,可能需要对其进行缩放,或对系统中的任何奖励进行缩放。如果需要更高,可以通过简单地乘以一个数字(例如 0.01、2、100)来实现。它们的比例需要有效地引导代理到目标。例如,为接近目标提供 1 的奖励,为目标本身提供 0.1 的奖励是不太合理的。
奖励是成比例的。如果你以相同的因子缩放每个正奖励和负奖励,通常不会对训练产生影响,除非是非常大或非常小的值。
总结来说,如果代理离目标还有 10 步,而它移动到一个离目标 11 步的地方,则reward将是-1。
这是更新后的 **move_agent**。
def move_agent(self, action):
# Map agent action to the correct movement
moves = {
0: (-1, 0), # Up
1: (1, 0), # Down
2: (0, -1), # Left
3: (0, 1) # Right
}
previous_location = self.agent_location
# Determine the new location after applying the action
move = moves[action]
new_location = (previous_location[0] + move[0], previous_location[1] + move[1])
done = False # The episode is not done by default
reward = 0 # Initialize reward
# Check for a valid move
if self.is_valid_location(new_location):
# Remove agent from old location
self.grid[previous_location[0]][previous_location[1]] = 0
# Add agent to new location
self.grid[new_location[0]][new_location[1]] = 1
# Update agent's location
self.agent_location = new_location
# Check if the new location is the reward location
if self.agent_location == self.goal_location:
# Reward for getting the goal
reward = 100
# Episode is complete
done = True
else:
# Calculate the distance before the move
previous_distance = np.abs(self.goal_location[0] - previous_location[0]) + \
np.abs(self.goal_location[1] - previous_location[1])
# Calculate the distance after the move
new_distance = np.abs(self.goal_location[0] - new_location[0]) + \
np.abs(self.goal_location[1] - new_location[1])
# If new_location is closer to the goal, reward = 1, if further, reward = -1
reward = (previous_distance - new_distance)
else:
# Slightly larger punishment for an invalid move
reward = -3
return reward, done
实现目标和尝试无效移动的奖励应保持一致。
步骤惩罚 还有一件事我们遗漏了。
代理当前没有因达到目标所需时间而受到惩罚。我们实现的奖励结构有许多净中性循环。它可能在两个位置之间来回移动而不积累任何惩罚。我们可以通过每步扣除一个小值来解决这个问题,使得远离目标的惩罚大于接近目标的奖励。这个说明应该会让情况更清楚。

奖励路径有和没有步骤惩罚 — 作者插图
想象代理从最左边的节点开始,并必须做出决策。如果没有步骤惩罚,它可以选择前进,然后返回任意次数,其总奖励将在最终移动到目标之前为 1。
所以从数学上讲,循环 1000 次然后再到达目标和直接到达目标是一样有效的。
试着想象在两种情况下循环,看惩罚是如何累积的(或者没有累积)。
让我们来实现它。
...
# If new_location is closer to the goal, reward = 0.9, if further, reward = -1.1
reward = (previous_distance - new_distance) - 0.1
...
就这样。代理现在应该受到激励去选择最短路径,防止循环行为。
好的,但重点是什么? 此时你可能会认为定义奖励系统并训练一个任务可以用更简单的算法完成是浪费时间。
你说得对。
我们这样做的原因是为了学习如何指导代理实现其目标。在这种情况下可能看起来很简单,但如果代理的环境中包含要拾取的物品、要战斗的敌人、要穿越的障碍物等等呢?
或者一个在现实世界中需要协调数十个传感器和电机以导航复杂和多变环境的机器人?
使用传统编程设计一个系统来完成这些任务将会非常困难,并且肯定不会像使用 RL 和良好的奖励结构那样自然或通用,以鼓励代理学习最佳策略。
强化学习在定义完成任务所需的精确步骤序列由于环境的复杂性和可变性而困难或不可能的应用中最为有用。你需要 RL 工作的唯一条件是能够定义什么是有用的行为,以及应该避免什么行为。
最终的环境方法——**step**。 现在我们可以定义代理和环境之间交互的核心,因为Environment的每个组件都到位了。
幸运的是,这非常简单。
def step(self, action):
# Apply the action to the environment, record the observations
reward, done = self.move_agent(action)
next_state = self.get_state()
# Render the grid at each step
if self.render_on:
self.render()
return reward, next_state, done
step首先在环境中移动代理并记录reward和done。然后它获取此交互之后的状态,next_state。然后如果render_on设置为 true,则会渲染网格。
最后,step返回记录的值,reward、next_state和done。
这些将是构建我们代理将从中学习的经验的重要组成部分。
恭喜!你已经正式完成了你的 DRL 健身环境的构建。
下面是完成的**Environment**类。
import random
import numpy as np
class Environment:
def __init__(self, grid_size, render_on=False):
self.grid_size = grid_size
self.render_on = render_on
self.grid = []
self.agent_location = None
self.goal_location = None
def reset(self):
# Initialize the empty grid as a 2d array of 0s
self.grid = np.zeros((self.grid_size, self.grid_size))
# Add the agent and the goal to the grid
self.agent_location = self.add_agent()
self.goal_location = self.add_goal()
# Render the initial grid
if self.render_on:
self.render()
# Return the initial state
return self.get_state()
def add_agent(self):
# Choose a random location
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Agent is represented by a 1
self.grid[location[0]][location[1]] = 1
return location
def add_goal(self):
# Choose a random location
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Get a random location until it is not occupied
while self.grid[location[0]][location[1]] == 1:
location = (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1))
# Goal is represented by a -1
self.grid[location[0]][location[1]] = -1
return location
def move_agent(self, action):
# Map agent action to the correct movement
moves = {
0: (-1, 0), # Up
1: (1, 0), # Down
2: (0, -1), # Left
3: (0, 1) # Right
}
previous_location = self.agent_location
# Determine the new location after applying the action
move = moves[action]
new_location = (previous_location[0] + move[0], previous_location[1] + move[1])
done = False # The episode is not done by default
reward = 0 # Initialize reward
# Check for a valid move
if self.is_valid_location(new_location):
# Remove agent from old location
self.grid[previous_location[0]][previous_location[1]] = 0
# Add agent to new location
self.grid[new_location[0]][new_location[1]] = 1
# Update agent's location
self.agent_location = new_location
# Check if the new location is the reward location
if self.agent_location == self.goal_location:
# Reward for getting the goal
reward = 100
# Episode is complete
done = True
else:
# Calculate the distance before the move
previous_distance = np.abs(self.goal_location[0] - previous_location[0]) + \
np.abs(self.goal_location[1] - previous_location[1])
# Calculate the distance after the move
new_distance = np.abs(self.goal_location[0] - new_location[0]) + \
np.abs(self.goal_location[1] - new_location[1])
# If new_location is closer to the goal, reward = 0.9, if further, reward = -1.1
reward = (previous_distance - new_distance) - 0.1
else:
# Slightly larger punishment for an invalid move
reward = -3
return reward, done
def is_valid_location(self, location):
# Check if the location is within the boundaries of the grid
if (0 <= location[0] < self.grid_size) and (0 <= location[1] < self.grid_size):
return True
else:
return False
def get_state(self):
# Flatten the grid from 2d to 1d
state = self.grid.flatten()
return state
def render(self):
# Convert to a list of ints to improve formatting
grid = self.grid.astype(int).tolist()
for row in grid:
print(row)
print('') # To add some space between renders for each step
def step(self, action):
# Apply the action to the environment, record the observations
reward, done = self.move_agent(action)
next_state = self.get_state()
# Render the grid at each step
if self.render_on:
self.render()
return reward, next_state, done
到目前为止我们已经讨论了很多内容。返回到全局视图并使用你的新知识重新评估每部分的互动可能会很有益,然后再继续前进。
6. 从经验中学习:经验回放
代理的模型和策略,以及环境的奖励结构和采取步骤的机制都已经完成,但我们需要某种方式来记住过去,以便代理能够从中学习。
这可以通过保存经验来实现。
每个经验都包括几项内容:
-
状态:在采取行动之前的状态。
-
行动:在这个状态下采取了什么行动。
-
奖励:代理根据其行动从环境中获得的正面或负面反馈。
-
下一状态:紧跟动作之后的状态,使代理能够不仅仅基于当前状态的结果行动,而是基于多个状态的提前信息。
-
完成:表示一个经验的结束,让代理知道任务是否已完成。它在每一步可以是 true 或 false。
这些术语你应该不陌生,但再看一遍也无妨!
每个经验都与代理的一个步骤相关联。这将提供训练所需的全部上下文。
ExperienceReplay 类
为了跟踪并在需要时提供这些经验,我们将定义最后一个类,ExperienceReplay。
from collections import deque, namedtuple
class ExperienceReplay:
def __init__(self, capacity, batch_size):
# Memory stores the experiences in a deque, so if capacity is exceeded it removes
# the oldest item efficiently
self.memory = deque(maxlen=capacity)
# Batch size specifices the amount of experiences that will be sampled at once
self.batch_size = batch_size
# Experience is a namedtuple that stores the relevant information for training
self.Experience = namedtuple('Experience', ['state', 'action', 'reward', 'next_state', 'done'])
该类将接受 capacity,一个定义我们一次保存的最大经验数量的整数值,以及 batch_size,一个决定我们每次为训练采样多少经验的整数值。
批处理经验 如果你还记得,Agent 类中的神经网络接受输入批次。虽然我们只用一个大小为一的批次进行预测,但这对于训练来说效率极低。通常,批次大小为 32 或更大的情况更为常见。
批处理输入进行训练有两个作用:
-
提高了效率,因为它允许并行处理多个数据点,减少计算开销,并更好地利用 GPU 或 CPU 资源。
-
帮助模型更一致地学习,因为它一次学习来自多种示例的内容,这可以提高其处理新数据的能力。
内存 memory 将是一个双端队列(deque)。这允许我们将新经验添加到前面,并且当达到由 capacity 定义的最大长度时,双端队列将删除它们,而不需要像 Python 列表那样移动每个元素。这在 capacity 设置为 10,000 或更多时可以大大提高速度。
经验 每个经验将被定义为一个 namedtuple。虽然许多其他数据结构也可以,但这将提高可读性,因为我们在训练时按需提取每一部分。
**add_experience** 和 **sample_batch** 实现 添加新经验和采样批次是相当直接的。
import random
def add_experience(self, state, action, reward, next_state, done):
# Create a new experience and store it in memory
experience = self.Experience(state, action, reward, next_state, done)
self.memory.append(experience)
def sample_batch(self):
# Batch will be a random sample of experiences from memory of size batch_size
batch = random.sample(self.memory, self.batch_size)
return batch
方法 add_experience 创建一个 namedtuple,包含经验的每一部分:state、action、reward、next_state 和 done,并将其附加到 memory 中。
sample_batch 同样简单。它从 memory 中获取并返回一个大小为 batch_size 的随机样本。

经验回放用于存储代理的经验以便批量处理和学习 — 图像来源于作者
最后一个方法 — **can_provide_sample** 最终,能够检查 memory 是否包含足够的经验以提供完整的样本,将在尝试获取训练批次之前非常有用。
def can_provide_sample(self):
# Determines if the length of memory has exceeded batch_size
return len(self.memory) >= self.batch_size
完成 **ExperienceReplay** 类…
import random
from collections import deque, namedtuple
class ExperienceReplay:
def __init__(self, capacity, batch_size):
# Memory stores the experiences in a deque, so if capacity is exceeded it removes
# the oldest item efficiently
self.memory = deque(maxlen=capacity)
# Batch size specifices the amount of experiences that will be sampled at once
self.batch_size = batch_size
# Experience is a namedtuple that stores the relevant information for training
self.Experience = namedtuple('Experience', ['state', 'action', 'reward', 'next_state', 'done'])
def add_experience(self, state, action, reward, next_state, done):
# Create a new experience and store it in memory
experience = self.Experience(state, action, reward, next_state, done)
self.memory.append(experience)
def sample_batch(self):
# Batch will be a random sample of experiences from memory of size batch_size
batch = random.sample(self.memory, self.batch_size)
return batch
def can_provide_sample(self):
# Determines if the length of memory has exceeded batch_size
return len(self.memory) >= self.batch_size
在保存每个经验和从中抽样的机制到位后,我们可以返回到Agent类,以最终启用学习。
7. 定义代理的学习过程:调整神经网络
训练神经网络的目标是使其产生的 Q 值准确地代表每个选择将提供的未来奖励。
本质上,我们希望网络学习预测每个决策的价值,不仅考虑即时奖励,还要考虑可能带来的未来奖励。
纳入未来奖励 为实现这一点,我们将后续状态的 Q 值纳入训练过程。
当代理采取行动并移动到新状态时,我们查看这个新状态中的 Q 值,以帮助确定先前行动的价值。换句话说,潜在的未来奖励会影响当前选择的感知价值。
**learn** 方法
import numpy as np
def learn(self, experiences):
states = np.array([experience.state for experience in experiences])
actions = np.array([experience.action for experience in experiences])
rewards = np.array([experience.reward for experience in experiences])
next_states = np.array([experience.next_state for experience in experiences])
dones = np.array([experience.done for experience in experiences])
# Predict the Q-values (action values) for the given state batch
current_q_values = self.model.predict(states, verbose=0)
# Predict the Q-values for the next_state batch
next_q_values = self.model.predict(next_states, verbose=0)
...
使用提供的批量数据experiences,我们将通过列表推导和之前在ExperienceReplay中定义的namedtuple值提取每一部分。然后我们将每个部分转换为 NumPy 数组,以提高效率并与模型的预期一致,如前所述。
最后,我们使用模型预测在当前状态下采取行动的 Q 值以及紧接着的状态。
在继续learn方法之前,我需要解释一下折扣因子的概念。
折扣未来奖励——gamma 的作用 直观地说,我们知道在其他条件相同的情况下,立即奖励通常会被优先考虑。(你希望今天还是下周拿到工资?)
从数学上表示这一点可能显得不太直观。考虑到未来,我们不希望它与现在同等重要(加权)。折扣未来的程度,即每个决策的影响降低程度,由 gamma(通常用希腊字母γ表示)定义。
Gamma 可以进行调整,较高的值鼓励规划,较低的值则鼓励更短视的行为。我们将使用默认值 0.99。
折扣因子通常在 0 和 1 之间。大于 1 的折扣因子会优先考虑未来而非现在,这会引入不稳定的行为,实际应用很少。
实现 gamma 和定义目标 Q 值 记住,在训练神经网络的背景下,这一过程依赖于两个关键要素:我们提供的输入数据和我们希望网络学习预测的对应输出。
我们需要向网络提供一些目标 Q 值,这些 Q 值是基于环境在特定状态和行动下给予的奖励,以及下一个状态中最佳行动的折扣(由 gamma 折扣)预测奖励更新的。
我知道这可能很难理解,但通过实现和示例会更好地解释。
import numpy as np
...
class Agent:
def __init__(self, grid_size, epsilon=1, epsilon_decay=0.995, epsilon_end=0.01, gamma=0.99):
...
self.gamma = gamma
...
...
def learn(self, experiences):
...
# Initialize the target Q-values as the current Q-values
target_q_values = current_q_values.copy()
# Loop through each experience in the batch
for i in range(len(experiences)):
if dones[i]:
# If the episode is done, there is no next Q-value
# [i, actions[i]] is the numpy equivalent of [i][actions[i]]
target_q_values[i, actions[i]] = rewards[i]
else:
# The updated Q-value is the reward plus the discounted max Q-value for the next state
# [i, actions[i]] is the numpy equivalent of [i][actions[i]]
target_q_values[i, actions[i]] = rewards[i] + self.gamma * np.max(next_q_values[i])
...
我们已经定义了类属性gamma,其默认值为 0.99。
然后,在获取我们上面实现的state和next_state的预测后,我们将target_q_values初始化为当前的 Q 值。这些将在以下循环中更新。
更新 **target_q_values** 我们遍历批次中的每个experience,有两种情况来更新这些值:
-
如果回合已
done,则该动作的target_q_value仅仅是给定的奖励,因为没有相关的next_q_value。 -
否则,回合尚未
done,该动作的target_q_value变为给定的奖励,加上next_q_values中预测的下一个动作的折扣 Q 值。
如果done为真,则更新:
target_q_values[i, actions[i]] = rewards[i]
如果done为假,则更新:
target_q_values[i, actions[i]] = rewards[i] + self.gamma * np.max(next_q_values[i])
这里的语法target_q_values[i, actions[i]]可能看起来令人困惑,但它本质上是第 i 个经验的 Q 值,对于动作actions[i]。
Experience in batch Reward from environment
v v
target_q_values[i, actions[i]] = rewards[i]
^
Index of the action chosen
这相当于 NumPy 中的 *[i][actions[i]]* 在 Python 列表中。记住每个动作是一个索引(0 到 3)。
如何 **target_q_values** 被更新
为了更清楚地说明这一点,我将展示target_q_values如何更紧密地与实际奖励对齐,随着训练的进行。记住我们在处理一个批次。这将是一个简单的三个样本的批次。
另外,确保你理解experiences中的条目是独立的。这意味着这不是一个步骤序列,而是从一组独立经验中随机抽取的样本。
假设actions、rewards、dones、current_q_values和next_q_values的值如下。
gamma = 0.99
actions = [1, 2, 2] # (down, left, left)
rewards = [1, -1, 100] # Rewards given by the environment for the action
dones = [False, False, True] # Indicating whether the episode is complete
current_q_values = [
[2, 5, -2, -3], # In this state, action 2 (index 1) is best so far
[1, 3, 4, -1], # Here, action 3 (index 2) is currently favored
[-3, 2, 6, 1] # Action 3 (index 2) has the highest Q-value in this state
]
next_q_values = [
[1, 4, -1, -2], # Future Q-values after taking each action from the first state
[2, 2, 5, 0], # Future Q-values from the second state
[-2, 3, 7, 2] # Future Q-values from the third state
]
然后我们将current_q_values复制到target_q_values中进行更新。
target_q_values = current_q_values
然后,对于批次中的每个经验,我们可以展示相关的值。
这不是代码,而只是每个阶段值的示例。如果你迷失了,确保回到初始值查看每个值的来源。
条目 1
i = 0 # This is the first entry in the batch (first loop)
# First entries of associated values
actions[i] = 1
rewards[i] = 1
dones[i] = False
target_q_values[i] = [2, 5, -2, -3]
next_q_values[i] = [1, 4, -1, -2]
因为这个经验的dones[i]为假,我们需要考虑next_q_values并应用gamma(0.99)。
target_q_values[i, actions[i]] = rewards[i] + 0.99 * max(next_q_values[i])
为什么获取next_q_values[i]的最大值?因为那将是下一个选择的动作,我们需要估计的奖励(Q 值)。
然后我们在索引对应于actions[i]的target_q_values中,将其更新为该状态/动作对的奖励加上下一个状态/动作对的折扣奖励。
这是该经验在更新后的目标值。
# Updated target_q_values[i]
target_q_values[i] = [2, 4.96, -2, -3]
^ ^
i = 0 action[i] = 1
如你所见,对于当前状态,选择 1(向下)现在更具吸引力,因为值更高且这种行为已经被强化。
自己计算这些可能有助于真正弄清楚。
条目 2
i = 1 # This is the second entry in the batch
# Second entries of associated values
actions[i] = 2
rewards[i] = -1
dones[i] = False
target_q_values[i] = [1, 3, 4, -1]
next_q_values[i] = [2, 2, 5, 0]
dones[i]在这里也是假的,因此我们需要考虑next_q_values。
target_q_values[i, actions[i]] = rewards[i] + 0.99 * max(next_q_values[i])
再次,在索引actions[i]处更新第 i 个经验的target_q_values。
# Updated target_q_values[i]
target_q_values[i] = [1, 3, 3.95, -1]
^ ^
i = 1 action[i] = 2
选择 2(向左)现在不再那么理想,因为 Q 值较低且这种行为被抑制。
条目 3
最后的条目在这一批中。
i = 2 # This is the third and final entry in the batch
# Second entries of associated values
actions[i] = 2
rewards[i] = 100
dones[i] = True
target_q_values[i] = [-3, 2, 6, 1]
next_q_values[i] = [-2, 3, 7, 2]
这个条目的dones[i]为真,表示这一轮已完成,不会再采取进一步的行动。这意味着我们在更新时不考虑next_q_values。
target_q_values[i, actions[i]] = rewards[i]
注意我们只是将target_q_values[i, action[i]]设置为rewards[i]的值,因为不会再有更多的行动 — 没有未来需要考虑。
# Updated target_q_values[i]
target_q_values[i] = [-3, 2, 100, 1]
^ ^
i = 2 action[i] = 2
在这种及类似状态中选择 2(左)现在会更具吸引力。
这是目标在智能体左侧的状态,因此当选择那个行动时,给予了全部奖励。
尽管它可能看起来相当令人困惑,但这个想法只是为了制作准确表示环境给予的奖励的更新 Q 值,以便提供给神经网络。这就是神经网络需要近似的内容。
尝试反向思考。由于到达目标的奖励相当可观,它将在状态中创建传播效应,最终到达智能体实现目标的状态。这就是 gamma 在考虑下一个状态及其在状态空间中奖励值向后传播的作用的力量。

奖励在状态空间中的波及效应 — 作者提供的图片
上面是 Q 值和折扣因子的简化版本,仅考虑目标的奖励,而不考虑增量奖励或惩罚。
选择网格中的任何一个单元格,并移动到质量最高的相邻单元格。你会发现它总是提供到达目标的最佳路径。
这一效果不是立竿见影的。它需要智能体探索状态和行动空间,逐渐学习和调整策略,建立对不同行动如何导致不同奖励的理解。
如果奖励结构经过精心设计,这将慢慢引导我们的智能体采取更有利的行动。
拟合神经网络 对于learn方法,最后需要做的是将智能体的神经网络与states及其相关的target_q_values配对。TensorFlow 将处理权重的更新,使其更准确地预测类似状态下的这些值。
...
def learn(self, experiences):
states = np.array([experience.state for experience in experiences])
actions = np.array([experience.action for experience in experiences])
rewards = np.array([experience.reward for experience in experiences])
next_states = np.array([experience.next_state for experience in experiences])
dones = np.array([experience.done for experience in experiences])
# Predict the Q-values (action values) for the given state batch
current_q_values = self.model.predict(states, verbose=0)
# Predict the Q-values for the next_state batch
next_q_values = self.model.predict(next_states, verbose=0)
# Initialize the target Q-values as the current Q-values
target_q_values = current_q_values.copy()
# Loop through each experience in the batch
for i in range(len(experiences)):
if dones[i]:
# If the episode is done, there is no next Q-value
target_q_values[i, actions[i]] = rewards[i]
else:
# The updated Q-value is the reward plus the discounted max Q-value for the next state
# [i, actions[i]] is the numpy equivalent of [i][actions[i]]
target_q_values[i, actions[i]] = rewards[i] + self.gamma * np.max(next_q_values[i])
# Train the model
self.model.fit(states, target_q_values, epochs=1, verbose=0)
唯一的新部分是self.model.fit(states, target_q_values, epochs=1, verbose=0)。fit有两个主要参数:输入数据和我们想要的目标值。在这种情况下,我们的输入是一批states,目标值是每个状态的更新 Q 值。
epochs=1只是设置你希望网络尝试拟合数据的次数。一个就足够了,因为我们希望它能够很好地泛化,而不是拟合到这个特定的批次。verbose=0只是告诉 TensorFlow 不要打印类似进度条的调试信息。
Agent类现在具备了从经验中学习的能力,但它还需要两个简单的方法 — save和load。
保存和加载训练好的模型 保存和加载模型可以防止我们每次需要时都进行完全的重训练。我们可以使用只需一个参数file_path的简单 TensorFlow 方法。
from tensorflow.keras.models import load_model
def load(self, file_path):
self.model = load_model(file_path)
def save(self, file_path):
self.model.save(file_path)
创建一个名为 models 的目录,或者其他你喜欢的名字,然后你可以在设定的间隔保存训练好的模型。这些文件以.h5 结尾。所以每当你想要保存模型时,只需调用agent.save('models/model_name.h5')。加载模型时也是如此。
完整 **Agent** 类
from tensorflow.keras.layers import Dense
from tensorflow.keras.models import Sequential, load_model
import numpy as np
class Agent:
def __init__(self, grid_size, epsilon=1, epsilon_decay=0.998, epsilon_end=0.01, gamma=0.99):
self.grid_size = grid_size
self.epsilon = epsilon
self.epsilon_decay = epsilon_decay
self.epsilon_end = epsilon_end
self.gamma = gamma
def build_model(self):
# Create a sequential model with 3 layers
model = Sequential([
# Input layer expects a flattened grid, hence the input shape is grid_size squared
Dense(128, activation='relu', input_shape=(self.grid_size**2,)),
Dense(64, activation='relu'),
# Output layer with 4 units for the possible actions (up, down, left, right)
Dense(4, activation='linear')
])
model.compile(optimizer='adam', loss='mse')
return model
def get_action(self, state):
# rand() returns a random value between 0 and 1
if np.random.rand() <= self.epsilon:
# Exploration: random action
action = np.random.randint(0, 4)
else:
# Add an extra dimension to the state to create a batch with one instance
state = np.expand_dims(state, axis=0)
# Use the model to predict the Q-values (action values) for the given state
q_values = self.model.predict(state, verbose=0)
# Select and return the action with the highest Q-value
action = np.argmax(q_values[0]) # Take the action from the first (and only) entry
# Decay the epsilon value to reduce the exploration over time
if self.epsilon > self.epsilon_end:
self.epsilon *= self.epsilon_decay
return action
def learn(self, experiences):
states = np.array([experience.state for experience in experiences])
actions = np.array([experience.action for experience in experiences])
rewards = np.array([experience.reward for experience in experiences])
next_states = np.array([experience.next_state for experience in experiences])
dones = np.array([experience.done for experience in experiences])
# Predict the Q-values (action values) for the given state batch
current_q_values = self.model.predict(states, verbose=0)
# Predict the Q-values for the next_state batch
next_q_values = self.model.predict(next_states, verbose=0)
# Initialize the target Q-values as the current Q-values
target_q_values = current_q_values.copy()
# Loop through each experience in the batch
for i in range(len(experiences)):
if dones[i]:
# If the episode is done, there is no next Q-value
target_q_values[i, actions[i]] = rewards[i]
else:
# The updated Q-value is the reward plus the discounted max Q-value for the next state
# [i, actions[i]] is the numpy equivalent of [i][actions[i]]
target_q_values[i, actions[i]] = rewards[i] + self.gamma * np.max(next_q_values[i])
# Train the model
self.model.fit(states, target_q_values, epochs=1, verbose=0)
def load(self, file_path):
self.model = load_model(file_path)
def save(self, file_path):
self.model.save(file_path)
你的深度强化学习环境的每个类现在都完成了!你已经成功地编码了Agent、Environment和ExperienceReplay。剩下的唯一任务就是主训练循环。
8. 执行训练循环:将所有部分整合在一起
我们已进入项目的最后阶段!我们编码的每一部分,Agent、Environment和ExperienceReplay,都需要某种交互方式。
这将是主要程序,其中每个回合都会运行,并且我们定义像epsilon这样的超参数。
虽然它相当简单,但我会在编码时将每一部分拆开,以便更加清晰。
初始化每一部分 首先,我们设置grid_size并使用我们创建的类来初始化每个实例。
from environment import Environment
from agent import Agent
from experience_replay import ExperienceReplay
if __name__ == '__main__':
grid_size = 5
environment = Environment(grid_size=grid_size, render_on=True)
agent = Agent(grid_size=grid_size, epsilon=1, epsilon_decay=0.998, epsilon_end=0.01)
experience_replay = ExperienceReplay(capacity=10000, batch_size=32)
...
现在我们已经准备好主训练循环所需的每一部分。
回合数和步骤限制 接下来,我们将定义训练中要运行的回合数和每个回合允许的最大步骤数。
限制步骤数有助于确保我们的代理不会陷入循环,并鼓励较短的路径。我们会相当慷慨地为 5x5 设置最大值为 200。对于较大的环境,这个值需要增加。
from environment import Environment
from agent import Agent
from experience_replay import ExperienceReplay
if __name__ == '__main__':
grid_size = 5
environment = Environment(grid_size=grid_size, render_on=True)
agent = Agent(grid_size=grid_size, epsilon=1, epsilon_decay=0.998, epsilon_end=0.01)
experience_replay = ExperienceReplay(capacity=10000, batch_size=32)
# Number of episodes to run before training stops
episodes = 5000
# Max number of steps in each episode
max_steps = 200
...
回合循环 在每个回合中,我们将重置environment并保存初始state。然后,我们执行每一步,直到done为真或达到max_steps。最后,我们保存模型。每一步的逻辑尚未完全实现。
from environment import Environment
from agent import Agent
from experience_replay import ExperienceReplay
if __name__ == '__main__':
grid_size = 5
environment = Environment(grid_size=grid_size, render_on=True)
agent = Agent(grid_size=grid_size, epsilon=1, epsilon_decay=0.998, epsilon_end=0.01)
experience_replay = ExperienceReplay(capacity=10000, batch_size=32)
# Number of episodes to run before training stops
episodes = 5000
# Max number of steps in each episode
max_steps = 200
for episode in range(episodes):
# Get the initial state of the environment and set done to False
state = environment.reset()
# Loop until the episode finishes
for step in range(max_steps):
# Logic for each step
...
if done:
break
agent.save(f'models/model_{grid_size}.h5')
注意,我们使用grid_size来命名模型,因为神经网络架构会因每个输入大小而异。尝试将 5x5 的模型加载到 10x10 的架构中将会导致错误。
步骤逻辑 最终,在步骤循环内部,我们将按照之前讨论的方式安排各部分之间的交互。
from environment import Environment
from agent import Agent
from experience_replay import ExperienceReplay
if __name__ == '__main__':
grid_size = 5
environment = Environment(grid_size=grid_size, render_on=True)
agent = Agent(grid_size=grid_size, epsilon=1, epsilon_decay=0.998, epsilon_end=0.01)
experience_replay = ExperienceReplay(capacity=10000, batch_size=32)
# Number of episodes to run before training stops
episodes = 5000
# Max number of steps in each episode
max_steps = 200
for episode in range(episodes):
# Get the initial state of the environment and set done to False
state = environment.reset()
# Loop until the episode finishes
for step in range(max_steps):
print('Episode:', episode)
print('Step:', step)
print('Epsilon:', agent.epsilon)
# Get the action choice from the agents policy
action = agent.get_action(state)
# Take a step in the environment and save the experience
reward, next_state, done = environment.step(action)
experience_replay.add_experience(state, action, reward, next_state, done)
# If the experience replay has enough memory to provide a sample, train the agent
if experience_replay.can_provide_sample():
experiences = experience_replay.sample_batch()
agent.learn(experiences)
# Set the state to the next_state
state = next_state
if done:
break
agent.save(f'models/model_{grid_size}.h5')
对于每个回合的每一步,我们首先打印回合数和步骤数,以便获得关于训练进度的信息。此外,你可以打印epsilon以查看代理动作的随机性百分比。这也有帮助,因为如果你想要停止,可以在相同的epsilon值下重新启动代理。
在打印信息后,我们使用agent策略从这个state中获取action,在environment中执行一步,并记录返回的值。
然后我们将state、action、reward、next_state和done保存为经验。如果experience_replay有足够的内存,我们将对agent进行随机经验批次训练。
最后,我们将state设置为next_state,并检查这一回合是否done。
一旦你运行了至少一个回合,你将会有一个保存的模型,可以加载并继续之前的操作或评估性能。
初始化agent后,只需使用它的加载方法,类似于我们保存时的操作 — agent.load(f’models/model_{grid_size}.h5')
你还可以在每一步中添加一个小的延迟,当你使用时间评估模型时 — time.sleep(0.5)。这会让每一步暂停半秒钟。确保包括import time。
完成训练循环
from environment import Environment
from agent import Agent
from experience_replay import ExperienceReplay
import time
if __name__ == '__main__':
grid_size = 5
environment = Environment(grid_size=grid_size, render_on=True)
agent = Agent(grid_size=grid_size, epsilon=1, epsilon_decay=0.998, epsilon_end=0.01)
# agent.load(f'models/model_{grid_size}.h5')
experience_replay = ExperienceReplay(capacity=10000, batch_size=32)
# Number of episodes to run before training stops
episodes = 5000
# Max number of steps in each episode
max_steps = 200
for episode in range(episodes):
# Get the initial state of the environment and set done to False
state = environment.reset()
# Loop until the episode finishes
for step in range(max_steps):
print('Episode:', episode)
print('Step:', step)
print('Epsilon:', agent.epsilon)
# Get the action choice from the agents policy
action = agent.get_action(state)
# Take a step in the environment and save the experience
reward, next_state, done = environment.step(action)
experience_replay.add_experience(state, action, reward, next_state, done)
# If the experience replay has enough memory to provide a sample, train the agent
if experience_replay.can_provide_sample():
experiences = experience_replay.sample_batch()
agent.learn(experiences)
# Set the state to the next_state
state = next_state
if done:
break
# Optionally, pause for half a second to evaluate the model
# time.sleep(0.5)
agent.save(f'models/model_{grid_size}.h5')
当你需要time.sleep或agent.load时,只需取消注释它们即可。
运行程序 试运行一下!你应该能够成功训练智能体完成一个大约 8x8 的网格环境。如果网格大小远大于此,训练会变得困难。
尝试看看你可以让环境变得多大。你可以做一些事情,比如向神经网络添加层和神经元、更改epsilon_decay,或给予更多的训练时间。这样做可以巩固你对每个部分的理解。
例如,你可能会注意到 *epsilon* 很快就达到了 *epsilon_end* 。如果你愿意,可以将 *epsilon_decay* 更改为 0.9998 或 0.99998。
随着网格大小的增加,网络接收到的状态会呈指数增长。
我在最后添加了一个简短的附加部分,修复了这个问题,并演示了有许多方法可以为智能体表示环境。
9. 总结
恭喜你完成了对强化学习和深度 Q 学习世界的全面探索!
尽管总有更多内容可以覆盖,你仍然可以获得重要的见解和技能。
在本指南中,你:
-
介绍了强化学习的核心概念以及为什么它在人工智能中至关重要。
-
构建了一个简单的环境,为智能体互动和学习奠定了基础。
-
定义了用于深度 Q 学习的智能体神经网络架构,使你的智能体能够在比传统 Q 学习更复杂的环境中做出决策。
-
理解了探索在利用学习策略之前的重要性,并实现了 Epsilon-Greedy 策略。
-
实现了奖励系统以引导智能体达到目标,并学习了稀疏奖励和密集奖励之间的区别。
-
设计了经验回放机制,让智能体能够从过去的经验中学习。
-
获得了在拟合神经网络中的实际操作经验,这是一个关键过程,智能体根据环境反馈改进其性能。
-
将所有这些部分结合在一个训练循环中,观察智能体的学习过程并进行调整,以获得最佳性能。
到现在为止,你应该对强化学习和深度 Q 学习有了信心。通过从头构建一个 DRL 环境,你不仅在理论上建立了坚实的基础,而且在实际应用中也得到了锻炼。
这些知识使你能够处理更复杂的 RL 问题,并为进一步探索这个激动人心的 AI 领域铺平了道路。

Agar.io 风格的游戏,其中代理被鼓励相互吞噬以获胜——作者制作的 GIF
上面是一个受 Agar.io 启发的网格游戏,其中代理被鼓励通过相互吞噬来增大体积。在每一步,环境都会使用 Python 库Matplotlib绘制在图上。围绕代理的框是它们的视野。这些作为环境中的状态以平铺网格的形式提供给它们,类似于我们在系统中所做的。
像这样的游戏以及其他许多应用,可以通过对你在这里制作的内容进行简单修改来实现。
但要记住,深度 Q 学习仅适用于离散的动作空间——即具有有限数量的不同动作的空间。对于连续的动作空间,如在基于物理的环境中,你需要探索 DRL 世界中的其他方法。
10. 附加:优化状态表示
不管你信不信,我们目前表示状态的方式并不是最优的。
实际上,这种方法非常低效。
对于 100x100 的网格,有 99,990,000 种可能的状态。考虑到输入的规模——10,000 个值,模型不仅需要非常大,还需要大量的训练数据。根据可用的计算资源,这可能需要几天或几周。
另一个缺点是灵活性。模型目前被固定在一个网格大小。如果你想使用不同大小的网格,你需要从头训练另一个模型。
我们需要一种表示状态的方法,这种方法能显著减少状态空间,并且适用于任何网格大小。
更好的方法 尽管有几种方法可以做到这一点,最简单且可能最有效的方法是使用相对于目标的距离。
而不是像这样表示 5x5 网格的状态:
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0]
它可以用两个值来表示:
[-2, -1]
使用这种方法可以将 100x100 网格的状态空间从 99,990,000 减少到 39,601!
不仅如此,它的泛化能力也更强。它只需学会当第一个值为负时,向下移动是正确的选择,而当第二个值为负时,向右移动是合适的选择,正值的情况则相反。
这使得模型只能探索状态空间的一部分。

以目标为中心的 25x25 代理决策热图——作者制作的 GIF
上图展示了在 25x25 网格上训练模型的学习进程。它展示了智能体在每个格子上的选择,颜色编码表示,目标位于中央。
起初,在探索阶段,智能体的策略完全不对。你可以看到它在目标上方时选择向上移动,在目标下方时选择向下移动,等等。
但在不到 10 集的情况下,它学会了一种策略,使其能够在最少的步骤内从任何格子到达目标。
这同样适用于目标位于任何位置的情况。

模型在不同目标位置应用的四个 25x25 热图 — 图片由作者提供
最后,它的学习能力非常强。

201x201 热图展示了 25x25 模型的决策,显示了泛化能力 — 图片由作者提供
这个模型只见过 25x25 的网格,但它可以在一个更大的环境中使用其策略——201x201。如此大的环境中有 1,632,200,400 种智能体与目标的排列组合!
让我们用这种彻底的改进来更新我们的代码。
实现 幸好,我们需要做的事情并不多就能使其工作。
首先需要更新Environment中的get_state。
def get_state(self):
# Calculate row distance and column distance
relative_distance = (self.agent_location[0] - self.goal_location[0],
self.agent_location[1] - self.goal_location[1])
# Unpack tuple into numpy array
state = np.array([*relative_distance])
return state
与网格的展平版本不同,我们计算目标的距离,并将其作为 NumPy 数组返回。*运算符仅仅是将元组解包成单独的组件。它的效果等同于这样做——state = np.array([relative_distance[0], relative_distance[1])。
同样,在move_agent中,我们可以将撞击边界的惩罚更新为与远离目标的惩罚相同。这样,当你更改网格大小时,智能体不会因移到原本训练区域之外而受到挫折。
def move_agent(self, action):
...
else:
# Same punishment for an invalid move
reward = -1.1
return reward, done
更新神经网络架构 目前我们的 TensorFlow 模型如下所示。为了简洁起见,我省略了其他所有内容。
class Agent:
def __init__(self, grid_size, ...):
self.grid_size = grid_size
...
self.model = self.build_model()
def build_model(self):
# Create a sequential model with 3 layers
model = Sequential([
# Input layer expects a flattened grid, hence the input shape is grid_size squared
Dense(128, activation='relu', input_shape=(self.grid_size**2,)),
Dense(64, activation='relu'),
# Output layer with 4 units for the possible actions (up, down, left, right)
Dense(4, activation='linear')
])
model.compile(optimizer='adam', loss='mse')
return model
...
如果你还记得,我们的模型架构需要有一致的输入。在这种情况下,输入大小依赖于grid_size。
使用我们更新的状态表示方式,无论grid_size是什么,每个状态只会有两个值。我们可以更新模型以适应这一点。同时,我们可以完全移除self.grid_size,因为Agent类不再依赖于它。
class Agent:
def __init__(self, ...):
...
self.model = self.build_model()
def build_model(self):
# Create a sequential model with 3 layers
model = Sequential([
# Input layer expects a flattened grid, hence the input shape is grid_size squared
Dense(64, activation='relu', input_shape=(2,)),
Dense(32, activation='relu'),
# Output layer with 4 units for the possible actions (up, down, left, right)
Dense(4, activation='linear')
])
model.compile(optimizer='adam', loss='mse')
return model
...
input_shape参数期望一个表示输入状态的元组。
(2,)表示一个具有两个值的一维数组。看起来像这样:
[-2, 0]
而(2,1),例如,一个二维数组,表示两行一列。看起来像这样:
[[-2],
[0]]
最后,我们将隐藏层中的神经元数量分别降低到 64 和 32。尽管这种简单的状态表示方式仍然可能有些过度,但运行速度应该足够快。
当你开始训练时,尝试看看模型有效学习所需的最少神经元数量。如果愿意,你甚至可以尝试移除第二层。
修复主要训练循环 训练循环需要很少的调整。让我们更新它以匹配我们的更改。
from environment import Environment
from agent import Agent
from experience_replay import ExperienceReplay
import time
if __name__ == '__main__':
grid_size = 5
environment = Environment(grid_size=grid_size, render_on=True)
agent = Agent(epsilon=1, epsilon_decay=0.998, epsilon_end=0.01)
# agent.load(f'models/model.h5')
experience_replay = ExperienceReplay(capacity=10000, batch_size=32)
# Number of episodes to run before training stops
episodes = 5000
# Max number of steps in each episode
max_steps = 200
for episode in range(episodes):
# Get the initial state of the environment and set done to False
state = environment.reset()
# Loop until the episode finishes
for step in range(max_steps):
print('Episode:', episode)
print('Step:', step)
print('Epsilon:', agent.epsilon)
# Get the action choice from the agents policy
action = agent.get_action(state)
# Take a step in the environment and save the experience
reward, next_state, done = environment.step(action)
experience_replay.add_experience(state, action, reward, next_state, done)
# If the experience replay has enough memory to provide a sample, train the agent
if experience_replay.can_provide_sample():
experiences = experience_replay.sample_batch()
agent.learn(experiences)
# Set the state to the next_state
state = next_state
if done:
break
# Optionally, pause for half a second to evaluate the model
# time.sleep(0.5)
agent.save(f'models/model.h5')
因为agent不再需要grid_size,我们可以移除它以防止任何错误。
我们也不再需要为每个grid_size给模型不同的名称,因为一个模型现在适用于任何大小。
如果你对ExperienceReplay感兴趣,它将保持不变。
请注意,没有一种适合所有情况的状态表示。在某些情况下,像我们这样提供完整的网格,或者像我在第九部分中做的那样提供部分网格是有意义的。目标是找到简化状态空间和提供足够信息之间的平衡,以便代理能够学习。
超参数 即使像我们这样简单的环境也需要调整超参数。记住,这些是我们可以更改的值,影响训练过程。
我们讨论的每一个都包括:
-
epsilon,epsilon_decay,epsilon_end(探索/利用) -
gamma(折扣因子) -
神经元数量和层数
-
batch_size,capacity(经验回放) -
max_steps
还有很多其他的,但我们将讨论的还有一个对于学习至关重要。
学习率 学习率(LR)是神经网络模型的一个超参数。
它基本上告诉神经网络每次拟合数据时调整其权重——用于输入转换的值——的程度。
学习率的值通常范围从 1 到 0.0000001,其中最常见的值是 0.01、0.001 和 0.0001。

次优学习率可能永远无法收敛到最优策略——作者提供的图像
如果学习率过低,可能无法足够快地更新 Q 值以学习最优策略,这个过程称为收敛。如果你注意到学习似乎停滞不前,或者完全没有,这可能是学习率不够高的一个迹象。
虽然这些关于学习率的图示大大简化了,但它们应该传达了基本的概念。

次优学习率导致 Q 值持续指数增长——作者提供的图像
另一方面,学习率过高可能导致值“爆炸”或变得越来越大。模型的调整过大,导致它发散——或者随着时间推移变得更差。
什么是完美的学习率? 一根绳子有多长?
在许多情况下,你只需使用简单的试错法。确定学习率是否是问题的好方法是检查模型的输出。
这正是我在训练这个模型时遇到的问题。切换到简化的状态表示后,它拒绝学习。代理实际上在广泛测试每个超参数后继续移动到网格的右下角。
这让我感到不解,所以我决定查看Agent get_action方法中模型输出的 Q 值。
Step 10
[[ 0.29763165 0.28393078 -0.01633328 -0.45749056]]
Step 50
[[ 7.173178 6.3558702 -0.48632553 -3.1968129 ]]
Step 100
[[ 33.015953 32.89661 33.11674 -14.883122]]
Step 200
[[573.52844 590.95685 592.3647 531.27576]]
...
Step 5000
[[37862352\. 34156752\. 35527612\. 37821140.]]
这是一个值爆炸的示例。
在 TensorFlow 中,我们用来调整权重的优化器 Adam,其默认学习率为 0.001。对于这种特定情况,这个值显然太高了。

平衡学习率,最终收敛到最佳策略——作者提供的图像
在测试了各种值之后,最佳点似乎是 0.00001。
让我们来实现这个。
from tensorflow.keras.optimizers import Adam
def build_model(self):
# Create a sequential model with 3 layers
model = Sequential([
# Input layer expects a flattened grid, hence the input shape is grid_size squared
Dense(64, activation='relu', input_shape=(2,)),
Dense(32, activation='relu'),
# Output layer with 4 units for the possible actions (up, down, left, right)
Dense(4, activation='linear')
])
# Update learning rate
optimizer = Adam(learning_rate=0.00001)
# Compile the model with the custom optimizer
model.compile(optimizer=optimizer, loss='mse')
return model
随意调整这些设置,观察 Q 值的变化。同时,确保导入 Adam。
最后,你可以再次开始训练!
热图代码 如果你感兴趣,下面是绘制你自己热图的代码,正如之前所示。
import matplotlib.pyplot as plt
import numpy as np
from tensorflow.keras.models import load_model
def generate_heatmap(episode, grid_size, model_path):
# Load the model
model = load_model(model_path)
goal_location = (grid_size // 2, grid_size // 2) # Center of the grid
# Initialize an array to store the color intensities
heatmap_data = np.zeros((grid_size, grid_size, 3))
# Define colors for each action
colors = {
0: np.array([0, 0, 1]), # Blue for up
1: np.array([1, 0, 0]), # Red for down
2: np.array([0, 1, 0]), # Green for left
3: np.array([1, 1, 0]) # Yellow for right
}
# Calculate Q-values for each state and determine the color intensity
for x in range(grid_size):
for y in range(grid_size):
relative_distance = (x - goal_location[0], y - goal_location[1])
state = np.array([*relative_distance]).reshape(1, -1)
q_values = model.predict(state)
best_action = np.argmax(q_values)
if (x, y) == goal_location:
heatmap_data[x, y] = np.array([1, 1, 1])
else:
heatmap_data[x, y] = colors[best_action]
# Plotting the heatmap
plt.imshow(heatmap_data, interpolation='nearest')
plt.xlabel(f'Episode: {episode}')
plt.axis('off')
plt.tight_layout(pad=0)
plt.savefig(f'./figures/heatmap_{grid_size}_{episode}', bbox_inches='tight')
只需将其导入到你的训练循环中,并根据需要运行。
下一步 一旦你有效地训练了你的模型并尝试了各种超参数,我鼓励你真正把它做成你自己的。
扩展系统的一些想法:
-
在代理和目标之间添加障碍
-
创建一个更为多样的环境,可能包括随机生成的房间和通道
-
实现一个多代理合作/竞争系统——捉迷藏
-
创建一个受乒乓球启发的游戏
-
实现资源管理,如饥饿或能量系统,其中代理需要在前往目标的途中收集食物
这是一个超越我们简单网格系统的示例:

Flappy Bird 风格的游戏,代理必须避开管道才能生存——作者提供的 GIF
使用Pygame,一个流行的 Python 2D 游戏库,我构建了一个 Flappy Bird 克隆。然后,我在我们预构建的Environment类中定义了交互、约束和奖励结构。
我将状态表示为代理的当前速度和位置、与最近管道的距离,以及开口的位置。
对于Agent类,我只是将输入大小更新为(4,),增加了神经网络的层数,并更新了网络以仅输出两个值——跳跃或不跳跃。
你可以在 GitHub repo的flappy_bird目录中找到并运行这些内容。确保pip install pygame。
这表明你所构建的系统适用于各种环境。你甚至可以让代理探索三维环境或执行更抽象的任务,如股票交易。
在扩展你的系统时,不要害怕在环境、状态表示和奖励系统上进行创新。就像代理一样,我们也通过探索学习得最好!
我希望从零开始构建 DRL 健身房让你领悟到了 AI 的美妙,并激励你深入探索。
这篇文章的灵感来源于 《Python 从零开始的神经网络》 和 youtube 系列 由 Harrison Kinsley (sentdex) 和 Daniel Kukieł 主讲。对话式风格和从零开始的代码实现真正巩固了我对神经网络的理解。
使用 Python 开发你自己的拼写检查工具包
原文:
towardsdatascience.com/develop-your-own-spelling-check-toolkit-with-python-740bf84a865d
使用 Python 创建一个有效检查拼写的应用程序
·发表于 Towards Data Science ·7 分钟阅读·2023 年 1 月 11 日
--

图片由 Olesia 🇺🇦 Buyar 提供,来源于 Unsplash
每当我开始撰写文章或其他工作相关内容时,我的主要关注点是将我的想法整理成文档或纸张。在这个过程中,我常常发现自己会遇到拼写错误或语法错误。
因此,建立你自己的拼写检查软件是一个绝妙的主意,尤其是当你遇到类似问题并希望优化工作时,而你专注的优先级是围绕生成和开发你的想法。
虽然已经有几个工具可以完成这个目的,但自己构建软件的好处在于你可以定制项目以进行额外改进。可以考虑的几种添加功能,如交互环境(使用 Tkinter 或其他类似库构建)、自然语言处理技术(如自动纠错)以及许多其他附加功能,都可以进一步增强项目。
同样重要的是要注意,虽然你可以对项目进行多种改进,但人工智能仍难以理解句子背后的真正语义。因此,幽默、讽刺或通用短语的陈述可能会被软件误解。我们将在未来的文章中讨论如何应对这些挑战。
我鼓励读者在继续阅读本文之前查看一个项目,该项目是如何使用 Python 构建语言过滤器的指南,链接如下。我们可以将本文和之前的工作整合起来,进一步升级项目,在此过程中可以屏蔽某些不适当的俚语,同时指出拼写错误。
如何在 Python 中构建语言过滤器的指南,适用于审查粗俗语言和其他不适当的内容
[towardsdatascience.com
开发拼写检查应用程序:

图片由 Dariusz Sankowski 提供,来源于 Unsplash
在本文的这一部分,我们将构建应用程序,以便根据适当的颜色突出显示拼写,以指示它们是否正确(绿色突出显示正确单词,而红色表示可能的错误)。本文的每个子部分将涵盖项目的所有主要组件。
我们的主要目标是为用户开发适当的拼写检查软件。因此,我们不会将错误单词直接转换为最接近的建议单词,就像自动纠正项目那样。我们将在未来的文章中探讨这种任务!现在,让我们开始使用 Python 构建拼写检查应用程序。
导入核心库:
我们在此任务中将使用的核心库是自然语言处理工具包(NLTK)及其对应的词库,包含了一列常见的英语单词。请注意,如果开发者愿意,他们可以选择创建自己的词典,包含所有他们想要添加到词汇中的单词。然而,这个过程可能相当繁琐,但对于特定任务而言是值得的。
我们将用于该项目的其他库是 term color 和正则表达式模块。本文中提到的所有库都可以通过简单的pip install命令安装。正则表达式库帮助我们从特定句子中预处理不必要的内容,以便只关注输入的单词。另一方面,term color 库帮助通过分配适当的颜色来区分正确和错误的单词。
以下是开始项目所需导入的所有库的列表。
# Importing the essential libraries for the required natural language processing task
import nltk
from nltk.corpus import words
from termcolor import colored
import re
预处理输入句子:
在本项目的这一部分,我们将重点关注从用户那里接受输入句子,以适当地对句子进行拼写测试。使用正则表达式替换命令,我们将用空格替换标点符号和其他特殊字符。我们采取以下步骤,以防止这些字符与单词一起包含。我们还可以将字符转换为小写,并准备评估句子。下面提供了执行以下操作的代码块。
# Accepting the input sentence by the user
sentence = input("Type in your sentence: \n")
# Modifying the sentence for further processing
new_sentence = re.sub('[^A-Za-z0-9 ]+', '', sentence)
final_sentence = new_sentence.lower()
# print(final_sentence)
word_list = []
print("\n")
print("Evaluated Sentence: ")
为数据拼写检查创建顺序模式:
在本节中,我们将拆分句子并逐个验证每个单词的拼写。我们可以使用拆分命令根据空格拆分每个单词。请注意,由于我们在前面的步骤中已经预处理了句子,因此所有标点符号和特殊字符都被相应地移除。
下一步是检查句子中的每个单词是否存在于nltk单词包的列表中。没有在包中包含的单词将使用我们之前导入的 term color 库功能以红色打印。所有正确的单词将以绿色解释,句子将提供给用户。下面是计算以下过程的代码块。
# Creating the loop for checking the spelling
for word in final_sentence.split():
# print(word)
if word not in words.words():
print(colored(word, "red"), end = " ")
else:
word_list.append(word)
print(colored(word, "green"), end = " ")
print("\n")
print(f"Words in red may be typed incorrectly. Please check the spelling!")
一旦我们完成了程序的编码,就可以通过命令提示符或交互式开发环境中的本地终端测试输出。
测试输出:

作者截图
一旦我们完成了项目的编码,就可以通过输入随机句子并实验程序的工作方式来测试输出。在大约 30 行代码中,我们可以发现我们成功地标记了可能拼写错误的单词为红色,同时所有拼写正确的单词标记为绿色。
该程序帮助推断特定句子或段落中的拼写错误,但我们可以进行一些改进,以使该项目更进一步。我们将在即将到来的部分中介绍一些好奇的开发人员可以探索的附加改进。
额外的改进:
在本节中,我们将查看我们可以添加的一些改进,以进一步改进该项目。开发人员可以开始着手下一步的一些值得注意的改进如下 —
-
添加语言过滤器,如前一节所述,用于审查粗俗语言或其他不当俚语,使得该项目可以部署在有效的框架上。
-
使用深度学习和自然语言处理来包含自动纠正技术和下一个单词预测。
-
开发一个用户界面用于以下项目,而不是在命令终端或 IDE 的编译器中工作。我提供了一个 Python 中七个最佳 UI 图形工具的列表,以高效开发你的项目,并附有一些入门代码,你可以从下面的链接查看。
## 7 个最佳 Python 开发者 UI 图形工具及入门代码
七款最佳 Python UI 图形工具,用于开发酷炫的用户界面技术
towardsdatascience.com
结论:

图片由 Aaron Burden 提供,来源于 Unsplash
“只有一种拼写方式的思想,真是极其贫乏。”
― 安德鲁·杰克逊
打字或写作是大多数人生活中不可或缺的元素。在打字时,遇到各种拼写错误是常见的,从稍微长一点的错误到中等错误,再到最简单的拼写错误。虽然有许多工具可以指出这些错误,但能够构建自己的自定义拼写检查应用程序,并进一步升级为最适合自己需求的工具,极具满足感。
在这篇文章中,我们学习了如何用 Python 代码构建一个简单的拼写检查软件,大约 30 行代码。我们利用自然语言处理工具库简化了从典型词典中积累大部分合理英语单词的过程。我们使用正则表达式来简化数据,并使用 term color 库来相应地突出正确和错误的单词。
如果你希望在我的文章发布后立即收到通知,请查看以下 链接 订阅邮件推荐。如果你愿意支持我和其他作者,请订阅下面的链接。
[## 使用我的推荐链接加入 Medium - Bharath K
阅读 Bharath K(以及 Medium 上其他成千上万的作家)的每一个故事。你的会员费直接支持…
bharath-k1297.medium.com](https://bharath-k1297.medium.com/membership?source=post_page-----740bf84a865d--------------------------------)
如果你对文章中提到的各种要点有任何疑问,请随时在评论区告诉我。我会尽快回复你。
查看一些与本文主题相关的其他文章,您可能会喜欢阅读!
讨论了一个优于 Jupyter Notebooks 的优秀替代选项,用于解读数据科学项目
## Jupyter Notebooks 的终极替代方案 ## 7 篇最佳研究论文,助力深度学习项目入门
七篇经受时间考验的最佳研究论文,将帮助你创造出色的项目
## 使用 Python 可视化 CPU、内存和 GPU 利用率
分析 CPU、内存使用情况和 GPU 组件,以便监控你的 PC 和深度学习项目
## 使用 Python 可视化 CPU、内存和 GPU 利用率
感谢大家一直看到最后。希望大家喜欢这篇文章。祝大家有美好的一天!
使用 NASA 的 Power API 创建气候 GPT
·
关注 发表在 Towards Data Science ·10 min read·2023 年 11 月 20 日
--
图像由 ChatGPT 创建
TL;DR
在本文中,我们探讨了 OpenAI 新推出的 GPTs 功能,它提供了一种无代码的方式,快速创建能够自动调用外部 API 以获取数据并生成代码以回答数据分析问题的 AI 代理。仅仅几个小时,我们就构建了一个能够基于 NASA Power API 的数据回答气候相关问题并执行数据分析任务的聊天机器人。OpenAI 创建的 GPT 用户体验非常出色,显著降低了创建最先进 AI 代理的门槛。不过,外部 API 调用配置可能会有些技术挑战,并且需要 API 提供 openapi.json 文件。此外,成本仍然未知,虽然在预览期间,GPTs 似乎对每天允许的交互次数有一些限制。然而,随着 OpenAI GPT 商店的即将推出,我们可能会看到这些 GPT AI 代理的爆炸式增长,即使现在它们已经提供了一些令人惊叹的功能。
什么是 GPT?
GPTs 最近由 OpenAI 推出,提供了一种让非技术用户创建由强大的 GPT-4 大型语言模型驱动的 AI 聊天代理的方式。虽然通过 LangChain 和 autogen 等第三方库已经可以实现大多数 GPTs 提供的功能,但 GPTs 提供了原生解决方案。随之而来的是一个光滑易用的界面和与 OpenAI 生态系统的紧密集成。重要的是,它们很快也将出现在新的 GPT 商店中,这提高了我们可能看到应用商店情况和 AI 代理爆炸性增长的可能性。或者也可能不是,这很难判断,但潜力无疑存在。
GPTs 具有一些非常强大的功能,特别是能够浏览网络、生成和运行代码,以及最强大的功能,能够与 API 进行通信以获取外部数据。最后这一点非常强大,因为它意味着基于任何使用 API 提供数据的数据存储创建 AI 代理应该是很容易的。
创建 GPT
GPTs 目前仅对 ChatGPT Plus 订阅用户开放。要创建一个,您需要访问 chat.openai.com/create,这将提示您提供有关您的 GPT 将做什么的一些细节以及您想使用的缩略图(可以使用 DALL-E-3 自动生成)。

对于这次分析,我使用了提示“创建一个气候指标聊天机器人,使用 NASA Power API 获取数据”。这创建了一个具有以下系统提示的 GPT(在“配置”下的“说明”字段中)…
The GPT is designed as a NASA Power API Bot, specialized in retrieving and
interpreting climate data for various locations. Its primary role is to
assist users in accessing and understanding climate-related information,
specifically by interfacing with NASA's Power API. It should focus on
providing accurate, up-to-date climate data such as temperature,
precipitation, solar radiation, and other relevant environmental
parameters.
To ensure accuracy and relevancy, the bot should avoid speculating on
data outside its provided scope and not offer predictions or
interpretations beyond what the API data supports. It should guide users in
formulating requests for data and clarify when additional details are
needed for a precise query.
In interactions, the bot should be factual and straightforward,
emphasizing clarity in presenting data. It should offer guidance on how
to interpret the data when necessary but maintain a neutral, informative
tone without personalization or humor.
The bot should explicitly ask for clarification if a user's request is
vague or lacks specific details needed to fetch the relevant data from
the NASA Power API.
根据我提供的单句内容,这似乎非常合理。当然,这可以根据口味进行调整,正如下文所述,这也是指导聊天机器人关于 API 调用的一个好地方。
配置能力
GPT 可以配置各种功能。对于我们的分析,我们将停用生成图像的能力,并保持浏览网页以及使用代码解释器生成和运行代码的能力。对于生产环境的 GPT,我可能会停用网页访问,并确保所有必需的数据由指定的 API 提供,但对于我们的分析,我们将其保留,因为它对于获取经度和纬度来调用 NASA Power API 是很方便的。

配置 API 访问
这是数据驱动 GPT 的核心,配置 API 集成。要做到这一点,您需要在您的 GPT 顶部点击‘配置’,然后向下滚动,点击‘创建操作’……

配置 GPT 以与 NASA 的 Power API 进行气候数据通信
这打开了一个部分,您可以通过提供或粘贴一个openapi.json(之前是 swagger)API 定义的链接来提供 API 的详细信息。
当然,这带来一个限制条件,即外部 API 需要有一个可用的 openapi.json 文件。尽管这在许多重要的 API 中非常普遍,但并非所有情况都如此。此外,默认的 openapi.json 通常需要进行一些调整才能使 GPT 正常工作。
NASA Power API
对于这个分析,我们将使用NASA 全球能源资源预测 (POWER) API 获取气候指标。这个令人惊奇的项目结合了广泛的数据和模型模拟,为点位提供一组气候指标的 API。有几个 API 端点,对于这个分析,我们将使用指标 API,其中包含一个openapi.json 规范,它被粘贴在 GPT 的操作配置窗格中。需要对其进行一些处理以 (i) 确保任何参数描述都在 300 字符以内;(ii) 添加一个‘servers’部分…
"servers": [
{
"url": "https://power.larc.nasa.gov"
}
],
当 GPT 用户界面中的所有异常都得到解决时,openapi.json 中指定的端点出现…

NASA Power API 指标端点,GPT 用户界面显示稍作调整的 openapi.json 规范
我本来会添加其他 API,比如气候学,但 OpenAI 不支持具有相同端点域的多个操作,即我无法为 NASA 提供的每个 openapi.json 创建一个操作。我必须将它们合并成一个较大的 openapi.json 文件,这并不是非常困难,但我选择为这个分析保持简单,只使用指标端点。
调整系统提示
通过直接与 API 进行一些实验,我发现每次调用中并未提供“user”字段,导致 API 异常。为了解决这个问题,我将这一点添加到系统提示中…
ALWAYS set 'user' API query parameter to be '<MY API ID>'
我创建了一个字母数字用户 ID 用于 API 调用。
测试我们的 GPT
在 GPT 编辑屏幕中,左侧窗格用于调整配置,右侧窗格用于预览。我发现预览提供了一些额外的调试信息,在发布的 GPT 中特别有用,尤其是用于调查 API 问题。
在询问“东京的平均降雨量是多少”时,我被要求确认使用 API…

在第一次使用 API 操作时,GPT 所有者会被提示确认。
我选择了“始终”,然后 GPT 调用了 API。然而,它收到的响应表明需要一个年份范围…

这相当酷,它已经建议使用 2018 年至 2022 年的解决方案,我通过回复“是”的方式接受了…

GPT 成功使用 NASA 的 Power API 获取并展示了东京的平均降雨量。
在API 页面上使用“试一下”按钮,输入上述年份范围和东京的纬度/经度为 35.6895/139.6917,我得到了一个响应。因为我对变量名称不熟悉,所以我询问了 GPT…

GPT 在展示 API 变量名称方面非常有帮助。
回到 API 响应,我们看到…
"PRECTOTCORR": {
"1": 69.1,
"2": 58.6,
"3": 154.9,
"4": 151.2,
"5": 158.4,
"6": 184.3,
"7": 247.3,
"8": 140.4,
"9": 226.8,
"10": 226.2,
"11": 100.3,
"12": 73.7
},
嗯,所以底层的 API 数据实际上提供了每个月的数据,但 GPT 的响应只取了前 5 个,并呈现为年度平均值。
所以事情看起来很不错,但正如我们经常发现的那样,我们需要稍微调整 GPT,以避免产生幻觉。让我们在 GPT 配置的“说明”部分提供更多关于 API 的上下文信息…
The API provides data averaged for the year range specified. If and data is
returned with 12 elements, this is likely a list of monthly means.
再试一次,“东京的平均降雨量是多少”…

现在这是正确的。通过一点提示改进性能的好例子。
让我们请求它进行一些分析,“请绘制月度平均值”…

GPT 可以使用从 API 检索的数据运行代码,以提供基本数据分析。
这非常巧妙!上图中的值与直接从 API 检索到的值相符。在其响应的末尾,有一个链接,你可以查看它生成并运行的代码…

好了,现在让我们尝试一些更复杂的事情。首先我询问可用的变量是什么…

GPT 可以查询文档 API 终端点。
它自动访问了配置端点以获取元数据。利用这个,让我们问“在过去 5 年中,南威尔士的尼思的相对湿度是否与太阳辐射相关?”…

它查询了 API 以获取数据,然后生成代码来创建图表。相当惊人,但没有回答完整的问题,所以让我们坚持问“请计算相关系数”…

点击链接查看代码…
import pandas as pd
import seaborn as sns
# Monthly data for relative humidity and solar radiation in Neath, South Wales from 2018 to 2022
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
relative_humidity = [4.2, 4.3, 4.7, 4.6, 4.0, 4.0, 3.7, 3.4, 3.4, 3.3, 3.1, 3.5] # Sampled values from MCWBR_DB or similar
solar_radiation = [0.84, 1.65, 2.81, 4.35, 5.36, 5.45, 5.45, 4.34, 3.29, 1.94, 1.03, 0.65] # Sampled from RADIATION_AVG
# Creating a DataFrame
data = pd.DataFrame({'Month': months, 'Relative Humidity': relative_humidity, 'Solar Radiation': solar_radiation})
# Plotting
plt.figure(figsize=(10, 6))
sns.scatterplot(data=data, x='Relative Humidity', y='Solar Radiation')
plt.title('Correlation between Relative Humidity and Solar Radiation in Neath, South Wales (2018-2022)')
plt.xlabel('Relative Humidity')
plt.ylabel('Solar Radiation')
plt.grid(True)
plt.show()
# Calculating the correlation coefficient between relative humidity and solar radiation
correlation = data['Relative Humidity'].corr(data['Solar Radiation'])
correlation
这是合理的。
好的,那多地点比较怎么样?首先,我将这添加到指令(系统提示)中以确保清楚…
Is asked about multiple locations call the API for each location to get data.
现在,让我们问“在过去 5 年中,斯瓦尔巴比巴戈德威尔士更潮湿吗?”…

由于 API 需要经纬度,GPT 确认了这种方法。如果我们配置了地理编码 API 作为一个操作,这就不需要了,但目前使用中心坐标就足够了。
GPT 调用了两个位置的 API,提取数据,并进行比较…

我在巴戈德长大,诚实地说,这里是个非常多雨的地方。直接调用 API,以上数值是正确的。
限制
在这次分析中出现了一些挑战。
首先,似乎每天允许的 GPT-4 交互次数有限。在测试了一两个小时后达到了这个限制,这似乎低于公布的GPT-4 限制,所以这可能与 GPT 的预览性质有关。这将阻止任何生产部署,但希望在 GPT 商店推出时能解决这个问题。
性能有时也可能稍慢,但考虑到 GPT 在调用外部 API 并运行代码,也不是不合理的。用户体验非常好,清楚地向用户表明事情正在进行中。
成本是一个未知数,或者说,我们没有看到成本的显著影响,但会继续跟踪。GPT 生成代码并分析来自 API 的长响应,因此令牌成本可能会成为许多组织使用它们的障碍。
结论和未来工作
在这次分析中,我们仅使用了‘指标’NASA Power API 端点。使用所有的 NASA Power 端点并结合地理编码,创建一个真正全面的气候聊天机器人将不会太费力。
GPT 提供了一种低代码的方式来开发最先进的 AI 代理,这些代理能够自动与 API 接口并生成代码以执行数据分析。它们可能会改变游戏规则,我们仅用了几小时就能创建一个相当先进的气候聊天机器人,而无需写一行代码!
目前它们还远远不完美,配置用户体验非常好,但在一些领域,如 API 错误报告,用户往往不得不猜测。外部 API 设置需要技术知识,有些 API 可能缺少所需的 openapi.json,导致实现难度增加。成本也可能很高,但由于 GPTs 仍处于预览阶段,具体情况难以判断。与 任何 LLM 应用一样,许多工作将是确保事实准确性,任何软件项目所需的典型设计和工程工作流程仍然适用。
GPTs 非常了不起,但还不是魔法……还没有。
参考文献
对于 NASA 全球能源资源预测 (POWER):“这些数据来自 NASA Langley 研究中心(LaRC)POWER 项目,该项目由 NASA 地球科学/应用科学计划资助。”
开发公司特定的 ChatGPT 是技术的三分之一和流程改进的三分之二

作者提供的图片
对开发基于 GPT 的公司特定虚拟助手所涉及的流程、角色和复杂性进行了实际概述
·
关注 在 Towards Data Science 发布 ·5 分钟阅读 ·2023 年 11 月 18 日
--
在整个 2023 年,我们一直在为 Enefit(波罗的海地区最大的能源公司之一)的员工开发基于 GPT 模型的虚拟助手。在第一篇文章中(点击阅读),我概述了问题、开发过程和初步结果。在本文中,我将深入探讨开发虚拟助手中非技术相关挑战的重要性。
介绍
在 2023 年初,显然大型语言模型的技术已经取得了突破。与过去十年那些常常让用户失望的聊天机器人不同,ChatGPT 展现出了精准、多才多艺和真正有用的特性。OpenAI 和微软决定通过开放 API 服务提供对其 GPT 模型的程序化访问,从而创造了实施公司特定用例的机会。
我们在启动 Enefit 虚拟助手项目时知道基础技术已经就绪,内部兴趣很高,而且软件开发挑战虽然新颖且复杂,但有优秀的专家可以解决。
在开发的早期阶段,这一叙述证明是正确的:近 80%的项目活动是软件开发任务,20%是非技术相关活动。随着项目的进展,这些比例发生了剧烈变化,导致需要全新的流程和角色。
数据/信息治理 2.0
虚拟助手只能根据基础文档的准确性提供公司特定的信息。换句话说,如果基础文档包含不正确、结构不佳或过时的信息,虚拟助手也无法提供更好的答案。这通常被称为 GIGO(垃圾进,垃圾出)原则,它设定了 AI 能力的基本限制。
因此,构建虚拟助手的一个重要部分是确保数据/信息的质量。这包括:
-
为每个文档/信息组指派一位负责人,负责信息的准确性。
-
同意一种反馈机制,让虚拟助手的用户能够报告错误回答或虚假信息。
-
建立反馈管理流程,以确保用户反馈能到达信息拥有者并得到处理。
从本质上讲,这意味着所有相关方都参与数据管理:提供持续反馈的用户和负责响应这些反馈的数据拥有者。
文档拥有者还可以通过丰富文档部分的关键词、测试虚拟助手的准确性、必要时重组内容、测试、改进、测试、改进等方式,提升虚拟助手查找其管辖信息的能力。实际上,信息拥有者应将虚拟助手视为需要协作的同事!
结束这一部分之前,我将谈谈微软的新 Copilot。目前,所有人的目光都集中在 Copilot 的发布上。大多数技术爱好者已经观看了演示视频,并期望它是一个即插即用的产品,能够半神奇地提供公司相关问题的好答案。然而,这种期望很可能会导致失望,因为即使是 Copilot 也无法免受“垃圾进,垃圾出”(GIGO)原则的影响。
从 Copilot 的营销视频中看,我们发现了关于文档管理要求的广泛文档。总之,微软期望 (阅读更多):
-
所有过时的文档将被删除。
-
所有文档应包含准确且相关的信息。
-
公司应建立新的数据治理流程以确保上述目标的实现。
-
文档应丰富关键词以增强搜索效果。
这些要求很高,尤其是当我们谈论存储在员工计算机上的文档时。
明确地说,我认为 Copilot 是一项了不起的新技术。然而,必须强调的是,没有数据治理流程,任何虚拟助理技术都无法成功实施。
指导虚拟助理
大型预训练语言模型(例如 GPT、Llama)是机械化的逻辑机器。这意味着如果我们希望它们履行特定角色(例如执行助理、合同助理、法律专家),我们需要指导它们并提供风格示例。
指导虚拟助理意味着向语言模型提供用户的问题和响应指南。例如,“你是 Enefit 的虚拟助理,了解公司政策和规则。如果你在现有信息中找不到答案,请说你不知道…”
通过这种类型的指导,我们可以指示虚拟助理如何行为,规定其回应时应使用的格式,并强调它应避免的内容。
然而,一般指南往往不够充分。例如,公司可能希望虚拟助理遵循特定的风格(正式、友好等)。在这种情况下,可以提供风格示例,实际上是问答对。由于语言模型训练是为了继续现有文本,虚拟助理会尝试以提供的风格示例类似的方式回答用户问题。
创建响应指南和风格示例、测试不同版本以及对其进行改进是虚拟助理开发的第三个重要部分。
‘虚拟助理培训师/指导者’这一角色完全新颖,只有在虚拟助理创建领域有深入了解的人才能有效担任。有效的虚拟助理开发需要软件开发人员、信息拥有者和虚拟助理培训师之间的密切合作,因为每个‘不佳’回应的原因可能都与不同的专家有关。
结论
使用现有技术开发一个 80%效率的聊天机器人是很容易的,但创建一个 95%质量的虚拟助手则是一个复杂的任务。
初看之下,人们可能会认为 80%已经足够,那么为什么要为最后的 20 个百分点付出这么多努力呢?实际上,根据过去十年与聊天机器人的经验,我们知道一个 80%准确的聊天机器人无法超越用户的“认知有用性门槛”。
这个认知有用性门槛是一个存在于我们所有人心中的隐性基准,但我们无法准确定义这个限制在哪里。然而,利用技术,我们可以迅速判断是否已超越这个限制。如果技术的质量低于这一门槛,我们将完全放弃使用该技术。
换句话说,80%和 95%之间的区别在于,在第一种情况下,没有人会开始使用这项技术,而在第二种情况下,它会成为许多员工的日常助手。
80%和 95%准确率之间的差异在于,第一种情况下没有人会开始使用这项技术,而在第二种情况下,它会成为许多员工的日常助手!
为了实现最后的 15-20%,需要实施一个数据管理系统,以确保基础信息的相关性,创建与虚拟助手开发相关的新角色和流程,对所有相关方进行新技术的培训,并在战略和运营层面支持实施和采纳。因此,技术仅占虚拟助手开发的三分之一,而组织和流程相关的挑战占其余部分。
为研究论文消化开发的自主双聊天机器人系统
关于概念、实施和演示的项目演练
·发表于Towards Data Science ·阅读时长 28 分钟·2023 年 8 月 14 日
--

图片由Aaron Burden提供,来自Unsplash
作为一名研究人员,阅读和理解科学论文一直是我日常工作的重要部分。我仍然记得在研究生阶段学到的如何高效消化论文的技巧。然而,由于每天都有无数的研究论文发表,我感到很难跟上最新的研究趋势和见解。旧有的技巧帮助有限。
随着大型语言模型(LLMs)的最新发展,情况开始发生变化。得益于其出色的上下文理解能力,LLMs 可以相当准确地从用户提供的文档中识别相关信息,并生成高质量的答案以回应用户关于文档的问题。基于这一思想,已经开发了大量的文档问答工具,有些工具专门设计用于帮助研究人员在相对较短的时间内理解复杂的论文。
虽然这无疑是一个进步,但在使用这些工具时我注意到了一些摩擦点。我面临的主要问题之一是提示工程。由于 LLM 的回答质量在很大程度上依赖于我的问题质量,我常常发现自己花费相当多的时间来制定“完美”的问题。当阅读不熟悉的研究领域的论文时,这尤其具有挑战性:我经常不知道该问什么问题。
这个经历让我思考:是否可以开发一个系统来自动化处理研究论文的问答过程?一个能够更高效且自主地提炼论文关键点的系统?
之前,我曾做过 一个我为语言学习开发双聊天机器人系统的项目。那里的概念简单而有效:通过让两个聊天机器人用用户指定的外语聊天,用户可以通过观察对话来学习语言的实际使用。这个项目的成功让我产生了一个有趣的想法:类似的双聊天机器人系统是否也有助于理解研究论文呢?
因此,在这篇博客中,我们将把这个想法变为现实。具体来说,我们将演示开发一个可以自主处理研究论文的双聊天机器人系统的过程。
为了让这次旅程变得有趣,我们将其视为一个软件项目并进行一个 Sprint:我们将从“创意阶段”开始,介绍利用双聊天机器人系统来解决我们的问题的概念。接下来是“Sprint 执行阶段”,在此期间,我们将逐步构建设计的功能。最后,我们将在“Sprint 回顾阶段”展示我们的演示,并在“Sprint 反思阶段”中反思所学到的内容和未来的机会。
准备好进行 Sprint 了吗?让我们开始吧!
这是我系列 LLM 项目的第二篇博客。第一篇是 构建一个 AI 驱动的语言学习应用,第三篇是 通过真实模拟训练数据科学软技能。欢迎查看!
目录
· 1. 概念:双聊天机器人系统 · 2. Sprint 计划:我们想要构建什么 · 3. 功能 1:文档嵌入引擎 · 4. 功能 2:双聊天机器人系统
∘ 4.1 抽象聊天机器人类
∘ 4.2 记者聊天机器人类
∘ 4.3 作者机器人类
∘ 4.4 快速测试:面试
· 5. 功能 3:用户交互
∘ 5.1 创建聊天环境(在 Jupyter Notebook 中)
∘ 5.2 实现 PDF 高亮功能
∘ 5.3 允许用户输入问题
∘ 5.4 允许下载生成的脚本
· 6. Sprint 回顾:展示演示! · 7. Sprint 反思
1. 概念:双聊天机器人系统
我们解决方案的基础在于双机器人系统的概念。顾名思义,这个系统涉及两个由大型语言模型驱动的聊天机器人进行自主对话。通过指定一个高级任务描述并分配相关角色给聊天机器人,用户可以引导对话朝着他们期望的方向发展。
举一个具体的例子:在我之前的项目中,我们开发了一个双机器人系统来辅助语言学习,学习者(用户)可以指定一个现实生活场景(例如,在餐厅用餐),并为聊天机器人分配角色(例如,机器人 1 作为服务员,机器人 2 作为顾客),然后两个机器人会模拟用户选择的外语对话,模仿在给定场景中分配角色之间的互动。这允许按需生成新鲜、特定场景的语言学习材料,从而帮助用户更好地理解现实生活中的语言使用。
那么,我们如何将这个概念适应于研究论文的自动消化呢?
关键在于角色分配。更具体地说,一个机器人可以担任“记者”的角色,其主要任务是进行采访以理解和提取研究论文中的关键见解。与此同时,另一个机器人可以扮演“作者”的角色,拥有对研究论文的全面访问权,负责回答“记者”机器人的提问。
当谈到互动时,记者机器人将启动对话并开始采访过程。然后,作者机器人将作为传统的文档问答引擎,根据研究论文的相关背景回答记者的问题。记者机器人随后会提出更多问题以进一步澄清。通过这种反复问答的过程,研究论文的关键贡献、方法论和发现可以被自动提取。

双机器人系统的工作流程示意图。(图片来源:作者)
上述双机器人系统引入了一种从传统用户-聊天机器人互动的转变:用户不再需要思考向 LLM 模型提出的正确问题,介绍的“记者”机器人将自动为用户提出合适的问题。这种方法可以绕过用户设计适当提示的需要,从而显著降低用户的认知负担。这在深入不熟悉的研究领域时尤其有用。总体而言,双机器人系统可能构成一种更用户友好、高效且引人入胜的方法,用于提炼复杂的科学研究论文。
接下来,让我们进行 Sprint 规划,并定义我们希望在这个项目中解决的几个用户故事。
2. 冲刺规划:我们想要构建的内容
确定概念后,接下来是规划我们当前的冲刺。根据敏捷开发的常规做法,我们的冲刺规划将围绕用户故事展开。
在敏捷开发中,用户故事是从最终用户的角度对功能或特性的简洁、非正式和简单的描述。这是一种在敏捷开发中常用的做法,用于以一种可理解和可操作的方式定义和传达需求。
- 🎯 用户故事 1: 文档嵌入
“作为用户,我希望将 PDF 格式的研究论文输入系统,并希望系统将我的输入论文转换成机器可读格式,以便双机器人系统能够高效地理解和分析它。”(由 GPT-4 生成)
这个用户故事集中在数据摄取上。本质上,我们需要构建一个数据处理管道,包括文档加载、拆分、嵌入创建和嵌入存储。
在这里,“嵌入”指的是文本数据的数值表示。通过创建研究论文每部分的数值表示,作者机器人可以更好地理解研究论文的语义含义,并能够准确回答记者机器人的问题。
此外,我们还需要一个数据库来存储研究论文计算出的嵌入。这一数据库需要能够被作者机器人快速访问,以便生成快速而准确的回答。
在第三部分中,我们将利用OpenAI Embeddings API和 Meta 的FAISS 向量存储来解决这个用户故事。
- 🎯 用户故事 2: 双机器人
“作为用户,我希望观察两个聊天机器人之间的自主对话——一个扮演‘记者’角色提问,另一个扮演‘作者’角色回答,这些对话来源于研究论文的内容。这将帮助我理解论文的关键点,而无需完整阅读或自己提出问题。”(由 GPT-4 生成)
这个用户故事代表了我们项目的基石:双机器人系统的开发。如“概念”部分所述,我们需要构建两种类型的聊天机器人类:一种能够提出一系列问题来查询论文的详细信息(即记者机器人),另一种能够利用文档嵌入生成对这些问题的全面回答(即作者机器人)。
在第四部分中,我们将通过使用LangChain框架来解决这个用户故事。
- 🎯 用户故事 3: 聊天环境
“作为用户,我希望有一个直观的聊天界面,在这里我可以实时观察聊天机器人的对话展开。”(由 GPT-4 生成)
这个用户故事的目标是构建一个聊天环境,用户可以查看记者和作者机器人之间生成的对话。为了符合 MVP(最简可行产品)的精神,我们将在 5.1 节使用简单的Jupyter 小部件来演示聊天环境。
- 🎯 用户故事 4: PDF 高亮
“作为用户,我希望能够根据聊天机器人的讨论在原研究论文中突出显示相关部分。这将帮助我快速找到对话中讨论的信息的来源。”(由 GPT-4 生成)
这个用户故事着重于为用户提供问答的可追溯性。对于每一个由作者机器人生成的回答,用户可以自然地理解讨论的信息源自研究论文的确切位置。这个功能不仅提升了我们双聊天机器人系统的透明度,还使得用户体验更加互动和引人入胜。
在 5.2 节,我们将利用 LangChain 的对话检索链来返回作者机器人用于生成回答的来源,并使用PyMuPDF库来突出显示原 PDF 中的相关文本。
- 🎯 用户故事 5: 用户输入
“作为用户,我希望能够在聊天机器人的对话过程中进行干预并提出自己的问题,这样我可以引导对话并从论文中提取我需要的信息。”(由 GPT-4 生成)
这个用户故事关注于用户参与的需求。虽然我们的目标双聊天机器人系统旨在自主运作,但我们仍需提供用户提出自己问题的选项。这个功能确保了对话不会仅仅按照机器人的设定方向进行,而是可以由用户的好奇心和兴趣来引导。此外,用户可能会受到第一次对话的启发,想要提出后续问题或深入挖掘他们特别感兴趣的某些方面。这些都强调了用户干预的重要性。
在 5.3 节,我们将通过升级 Jupyter Notebook 中的用户界面来处理这个用户故事。
- 🎯 用户故事 6: 下载脚本
“作为用户,我希望能够下载聊天机器人对话的记录。这将允许我离线查看要点或与我的同事分享信息。”(由 GPT-4 生成)
这个用户故事关注于生成内容的可访问性和可分享性。虽然用户可以在专用的聊天环境中查看对话,但提供一个可以供用户后续查看和分享的讨论记录是很有益的。
在 5.4 节,我们将使用PDFDocument库将生成的脚本转换为 PDF 文件,以供用户下载。
规划到此为止,现在是时候开始工作了!

我们规划的用户故事。(作者提供的图片)
3. 特性 1:文档嵌入引擎
让我们实现我们论文消化应用的第一个功能:文档嵌入引擎。在这里,我们将构建一个数据处理类,具备文档加载、拆分、嵌入创建和存储的功能。这解决了我们的第一个用户故事:
“作为用户,我希望将 PDF 格式的研究论文输入系统,并希望系统将我的输入论文转换为机器可读格式,以便双重聊天机器人系统能够有效理解和分析。”(由 GPT-4 生成)
我们首先创建一个 embedding_engine.py 文件,并导入必要的库:
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.document_loaders import PyMuPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.vectorstores import FAISS
from langchain.chains.summarize import load_summarize_chain
from langchain.chat_models import ChatOpenAI
from langchain.utilities import ArxivAPIWrapper
import os
然后,我们使用 OpenAI 嵌入 API 实例化了一个嵌入模型:
class Embedder:
"""Embedding engine to create doc embeddings."""
def __init__(self, engine='OpenAI'):
"""Specify embedding model.
Args:
--------------
engine: the embedding model.
For a complete list of supported embedding models in LangChain,
see https://python.langchain.com/docs/integrations/text_embedding/
"""
if engine == 'OpenAI':
# Reminder: need to set up openAI API key
# (e.g., via environment variable OPENAI_API_KEY)
self.embeddings = OpenAIEmbeddings()
else:
raise KeyError("Currently unsupported chat model type!")
接下来,我们定义了加载和处理 PDF 文件的函数:
def load_n_process_document(self, path):
"""Load and process PDF document.
Args:
--------------
path: path of the paper.
"""
# Load PDF
loader = PyMuPDFLoader(path)
documents = loader.load()
# Process PDF
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
self.documents = text_splitter.split_documents(documents)
在这里,我们使用了 PyMuPDFLoader 来加载 PDF 文件,该工具在底层利用 PyMuPDF 库解析 PDF 文件。返回的 documents 变量是 LangChain Document() 对象的列表。每个 Document() 对象对应原始 PDF 的一页,页面内容存储在 page_content 键中,相关的元数据(例如,页码等)存储在 metadata 键中。
解析加载的 PDF 后,我们使用了 LangChain 的 RecursiveCharacterTextSplitter 将原始 PDF 拆分成多个较小的块。由于作者机器人稍后将使用 PDF 中的相关文本来回答问题,创建小块文本不仅可以帮助作者机器人专注于具体细节以回答问题,还可以确保提供给作者机器人的上下文不会超出所用 LLM 的令牌限制。
接下来,我们设置向量存储以管理文本嵌入向量:
def create_vectorstore(self, store_path):
"""Create vector store for doc Q&A.
For a complete list of vector stores supported by LangChain,
see: https://python.langchain.com/docs/integrations/vectorstores/
Args:
--------------
store_path: path of the vector store.
Outputs:
--------------
vectorstore: the created vector store for holding embeddings
"""
if not os.path.exists(store_path):
print("Embeddings not found! Creating new ones")
self.vectorstore = FAISS.from_documents(self.documents, self.embeddings)
self.vectorstore.save_local(store_path)
else:
print("Embeddings found! Loaded the computed ones")
self.vectorstore = FAISS.load_local(store_path, self.embeddings)
return self.vectorstore
在这里,我们使用了 Facebook AI 相似度搜索(FAISS)库作为我们的向量存储,它接受加载的 PDF 和嵌入引擎作为构造函数的输入。创建的 self.vectorstore 存储了我们之前创建的每个 PDF 块的嵌入向量。在查询时,它将调用嵌入引擎来嵌入问题,然后检索与嵌入查询“最相似”的嵌入向量。与最相似嵌入向量对应的文本将作为上下文输入到作者机器人,以帮助生成答案。这个过程被称为向量搜索,是文档问答的核心。
最后,我们创建了一个辅助函数来生成论文的简短摘要。这将在稍后为记者机器人设定场景时非常有用。
def create_summary(self, llm_engine=None):
"""Create paper summary.
The summary is created by using LangChain's summarize_chain.
Args:
--------------
llm_engine: backbone large language model.
Outputs:
--------------
summary: the summary of the paper
"""
if llm_engine is None:
raise KeyError("please specify a LLM engine to perform summarization.")
elif llm_engine == 'OpenAI':
# Reminder: need to set up openAI API key
# (e.g., via environment variable OPENAI_API_KEY)
llm = ChatOpenAI(
model_name="gpt-3.5-turbo",
temperature=0.8
)
else:
raise KeyError("Currently unsupported chat model type!")
# Use LLM to summarize the paper
chain = load_summarize_chain(llm, chain_type="stuff")
summary = chain.run(self.documents[:20])
return summary
我们求助于 LLM 来创建摘要。从技术上讲,我们可以通过使用 LangChain 的 load_summarize_chain 来实现这一目标,该方法接受 LLM 模型和总结方法作为输入。
在摘要方法方面,我们使用了stuff方法,它简单地将所有文档“填充”到一个上下文中,并提示 LLM 生成摘要。有关其他更高级的方法,请参阅 LangChain 的官方页面。
太棒了!现在我们已经开发了Embedder类来处理文档加载、拆分以及嵌入创建和存储,我们可以转到我们应用的核心部分:双聊天机器人系统。
4. 功能 2:双聊天机器人系统
在本节中,我们处理我们的第二个用户故事:
“作为用户,我想观察两个聊天机器人之间的自主对话——一个扮演‘记者’提问,另一个扮演‘作者’回答,问题和回答都来自研究论文的内容。这将帮助我理解论文的关键点,而无需完整阅读论文或自己提出问题。”(由 GPT-4 生成)
我们将首先创建一个抽象基类,用于定义聊天机器人的共同行为。之后,我们将开发继承自聊天机器人基类的记者机器人和作者机器人。我们将所有类定义放在chatbot.py中。
4.1 抽象聊天机器人类
由于我们的记者机器人和作者机器人有很多相似之处(因为它们都是角色扮演机器人),将它们共享的行为定义封装在一个抽象基类中是一个好习惯:
from abc import ABC, abstractmethod
from langchain.chat_models import ChatOpenAI
class Chatbot(ABC):
"""Class definition for a single chatbot with memory, created with LangChain."""
def __init__(self, engine):
"""Initialize the large language model and its associated memory.
The memory can be an LangChain emory object, or a list of chat history.
Args:
--------------
engine: the backbone llm-based chat model.
"""
# Instantiate llm
if engine == 'OpenAI':
# Reminder: need to set up openAI API key
# (e.g., via environment variable OPENAI_API_KEY)
self.llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.8)
else:
raise KeyError("Currently unsupported chat model type!")
@abstractmethod
def instruct(self):
"""Determine the context of chatbot interaction.
"""
pass
@abstractmethod
def step(self):
"""Action produced by the chatbot.
"""
pass
@abstractmethod
def _specify_system_message(self):
"""Prompt engineering for chatbot.
"""
pass
我们定义了三个常用方法:
-
instruct:这个方法用于设置聊天机器人并将内存附加到它上面。 -
step:这个方法用于向聊天机器人提供输入并接收机器人的回应。 -
specify_system_message:这个方法用于给聊天机器人提供具体的指令,说明它在对话中应该如何行为。
有了聊天机器人模板,我们准备创建两个具体的聊天机器人角色,即记者机器人和作者机器人。
4.2 记者聊天机器人类
记者机器人的角色是采访作者机器人并从研究论文中提取关键见解。考虑到这一点,让我们用具体的代码填充模板方法。
from langchain.memory import ConversationBufferMemory
class JournalistBot(Chatbot):
"""Class definition for the journalist bot, created with LangChain."""
def __init__(self, engine):
"""Setup journalist bot.
Args:
--------------
engine: the backbone llm-based chat model.
"""
# Instantiate llm
super().__init__(engine)
# Instantiate memory
self.memory = ConversationBufferMemory(return_messages=True)
在构造函数方法中,除了指定一个主干 LLM,记者机器人另一个重要的组件是内存对象。内存跟踪对话历史,并帮助记者机器人避免重复或无关的问题,并生成有意义的后续问题。从技术上讲,我们通过使用 LangChain 提供的ConversationBufferMemory来实现这一点,它简单地将最后几次输入/输出附加到聊天机器人的当前输入中。
接下来,我们通过创建一个 ConversationChain 来设置记者聊天机器人,使用之前定义的骨干 LLM、内存对象以及聊天机器人的提示。请注意,我们还指定了 topic(论文主题)和 abstract(论文摘要),这些将在稍后用于向记者机器人提供论文的背景信息。
from langchain.chains import ConversationChain
from langchain.prompts import (
ChatPromptTemplate,
MessagesPlaceholder,
SystemMessagePromptTemplate,
HumanMessagePromptTemplate
)
def instruct(self, topic, abstract):
"""Determine the context of journalist chatbot.
Args:
------
topic: the topic of the paper
abstract: the abstract of the paper
"""
self.topic = topic
self.abstract = abstract
# Define prompt template
prompt = ChatPromptTemplate.from_messages([
SystemMessagePromptTemplate.from_template(self._specify_system_message()),
MessagesPlaceholder(variable_name="history"),
HumanMessagePromptTemplate.from_template("""{input}""")
])
# Create conversation chain
self.conversation = ConversationChain(memory=self.memory, prompt=prompt,
llm=self.llm, verbose=False)
在 LangChain 中,用于指示聊天机器人的提示生成和接收是通过不同的提示模板处理的。对于我们当前的应用程序,最关键的部分是设置 SystemMessagePromptTemplate,因为它允许我们给记者机器人提供 高级目的,并定义其期望的行为。
以下是指令的详细信息。请注意,指令/提示是通过 ChatGPT (GPT-4) 生成和优化的。这是有利的,因为在当前情况下,LLM 生成的提示往往比人工编写的提示考虑更多细节。此外,使用 LLM 生成高级指令代表了一种更具可扩展性的解决方案,可以将系统适应到“记者-作者”互动之外的其他情境。
def _specify_system_message(self):
"""Specify the behavior of the journalist chatbot.
The prompt is generated and optimized with GPT-4.
Outputs:
--------
prompt: instructions for the chatbot.
"""
prompt = f"""You are a technical journalist interested in {self.topic},
Your task is to distill a recently published scientific paper on this topic through
an interview with the author, which is played by another chatbot.
Your objective is to ask comprehensive and technical questions
so that anyone who reads the interview can understand the paper's main ideas and contributions,
even without reading the paper itself.
You're provided with the paper's summary to guide your initial questions.
You must keep the following guidelines in mind:
- Focus exclusive on the technical content of the paper.
- Avoid general questions about {self.topic}, focusing instead on specifics related to the paper.
- Only ask one question at a time.
- Feel free to ask about the study's purpose, methods, results, and significance,
and clarify any technical terms or complex concepts.
- Your goal is to lead the conversation towards a clear and engaging summary.
- Do not include any prefixed labels like "Interviewer:" or "Question:" in your question.
[Abstract]: {self.abstract}"""
return prompt
在这里,我们为记者机器人提供了论文的研究领域和摘要,作为初始问题的基础。这反映了现实世界中记者最初对论文了解不多,需要通过提问来获取更多信息的情境。
最后,我们需要一个 step 方法来与记者机器人互动:
def step(self, prompt):
"""Journalist chatbot asks question.
Args:
------
prompt: Previos answer provided by the author bot.
"""
response = self.conversation.predict(input=prompt)
return response
在这种情况下,输入提示将是作者机器人对记者机器人上一个问题的回答。如果对话尚未开始,输入提示将简单为“开始对话”,以提示记者机器人启动采访。
这就是记者机器人的全部内容。现在让我们转向作者机器人。
4.3 作者机器人类
作者机器人的角色是根据研究论文回答记者机器人提出的问题。以下是作者机器人的构造方法:
class AuthorBot(Chatbot):
"""Class definition for the author bot, created with LangChain."""
def __init__(self, engine, vectorstore, debug=False):
"""Select backbone large language model, as well as instantiate
the memory for creating language chain in LangChain.
Args:
--------------
engine: the backbone llm-based chat model.
vectorstore: embedding vectors of the paper.
"""
# Instantiate llm
super().__init__(engine)
# Instantiate memory
self.chat_history = []
# Instantiate embedding index
self.vectorstore = vectorstore
self.debug = debug
这里有两点变化:首先,与记者机器人不同,作者机器人应该能够访问完整的论文。因此,我们之前创建的向量存储需要提供给构造函数。另外,请注意,我们不再使用内存对象(例如ConversationBufferMemory)来跟踪聊天记录。相反,我们将简单地使用一个列表来存储历史记录,并在之后明确传递给作者机器人。列表的每个元素将是一个 (query, answer) 的元组。在 LangChain 中,两种维护对话历史的方法都被支持。
接下来,我们为作者机器人设置对话链。
from langchain.chains import ConversationalRetrievalChain
def instruct(self, topic):
"""Determine the context of author chatbot.
Args:
-------
topic: the topic of the paper.
"""
# Specify topic
self.topic = topic
# Define prompt template
qa_prompt = ChatPromptTemplate.from_messages([
SystemMessagePromptTemplate.from_template(self._specify_system_message()),
HumanMessagePromptTemplate.from_template("{question}")
])
# Create conversation chain
self.conversation_qa = ConversationalRetrievalChain.from_llm(llm=self.llm, verbose=self.debug,
retriever=self.vectorstore.as_retriever(
search_kwargs={"k": 5}),
return_source_documents=True,
combine_docs_chain_kwargs={'prompt': qa_prompt})
由于作者机器人需要通过首先检索相关背景来回答问题,我们采用了 ConversationalRetrievalChain。引用 LangChain 的官方文档:
对话检索链首先将聊天记录(无论是明确传递的还是从提供的记忆中检索到的)和查询合并成一个独立的问题,然后从检索器中查找相关文档,最后将这些文档和查询传递给问题回答链以返回响应。
因此,除了基础 LLM,我们还需要为链提供一个向量存储。请注意,这里我们通过search_kwargs指定了返回的相关文档(PDF 块)的数量。通常,选择正确的数量不是一项简单的任务,需要仔细考虑准确性、相关性、全面性和计算资源的平衡。最后,我们将return_source_documents设置为 True,这对确保问答过程中的透明性和可追溯性非常重要。
要与作者机器人互动:
def step(self, prompt):
"""Author chatbot answers question.
Args:
------
prompt: question raised by journalist bot.
Outputs:
------
answer: the author bot's answer
source_documents: documents that author bot used to answer questions
"""
response = self.conversation_qa({"question": prompt, "chat_history": self.chat_history})
self.chat_history.append((prompt, response["answer"]))
return response["answer"], response["source_documents"]
如前所述,我们明确将聊天记录(以前的查询-回答元组列表)提供给对话链。因此,我们还需要手动将新获得的查询-回答元组附加到聊天记录中。对于响应,我们不仅得到答案,还得到作者机器人用于生成答案的源文档(PDF 块),这些文档将在稍后用于突出显示 PDF 中的相应文本。
最后,我们告知作者机器人角色并指定详细的指令。与记者机器人一样,作者机器人的指令/提示也由 ChatGPT(GPT-4)生成和优化。
def _specify_system_message(self):
"""Specify the behavior of the author chatbot.
The prompt is generated and optimized by GPT-4.
Outputs:
--------
prompt: instructions for the chatbot.
"""
prompt = f"""You are the author of a recently published scientific paper on {self.topic}.
You are being interviewed by a technical journalist who is played by another chatbot and
looking to write an article to summarize your paper.
Your task is to provide comprehensive, clear, and accurate answers to the journalist's questions.
Please keep the following guidelines in mind:
- Try to explain complex concepts and technical terms in an understandable way, without sacrificing accuracy.
- Your responses should primarily come from the relevant content of this paper,
which will be provided to you in the following, but you can also use your broad knowledge in {self.topic} to
provide context or clarify complex topics.
- Remember to differentiate when you are providing information directly from the paper versus
when you're giving additional context or interpretation. Use phrases like 'According to the paper...' for direct information,
and 'Based on general knowledge in the field...' when you're providing additional context.
- Only answer one question at a time. Ensure that each answer is complete before moving on to the next question.
- Do not include any prefixed labels like "Author:", "Interviewee:", Respond:", or "Answer:" in your answer.
"""
prompt += """Given the following context, please answer the question.
{context}"""
return prompt
这就是构建作者机器人的全部内容。
4.4 快速测试:采访
该是时候带两个机器人“试驾”一下了!
为了检验开发的记者机器人和作者机器人是否能进行有意义的对话以达到消化论文的目的,我们选择了一篇样本科学研究论文并进行测试。
最近在研究物理信息机器学习时,我选择了一篇名为“改进物理信息神经网络的模型集成训练”(CC BY 4.0 许可)的 arXiv 论文进行测试。
paper = 'Improved Training of Physics-Informed Neural Networks with Model Ensembles'
# Create embeddings
embedding = Embedder(engine='OpenAI')
embedding.load_n_process_document("../Papers/"+paper+".pdf")
# Set up vectorstore
vectorstore = embedding.create_vectorstore(store_path=paper)
# Fetch paper summary
paper_summary = embedding.create_summary(llm_engine='OpenAI')
# Instantiate journalist and author bot
journalist = JournalistBot('OpenAI')
author = AuthorBot('OpenAI', vectorstore)
# Provide instruction
journalist.instruct(topic='physics-informed machine learning', abstract=paper_summary)
author.instruct('physics-informed machine learning')
# Start conversation
for i in range(4):
if i == 0:
question = journalist.step('Start the conversation')
else:
question = journalist.step(answer)
print("👨🏫 Journalist: " + question)
answer, source = author.step(question)
print("👩🎓 Author: " + answer)
生成的对话脚本如下所示。请注意,为了节省空间,一些作者机器人的回答未完全显示:

开发的记者机器人和作者机器人的采访。(图片由作者提供)
由于作者机器人仅被动回答问题(即传统的问答代理),我们将重点放在记者机器人的行为上,以评估它是否能够有效引导采访。在这里,我们可以看到记者机器人首先提出了一个关于论文的一般性问题(动机),然后调整问题以深入探讨提议策略的方法。总体而言,开发的记者机器人的行为符合我们的预期,能够进行采访以提炼出论文的关键点。表现不错😃
5. 特性 3:用户互动
在本节中,我们将之前的实验封装到一个合适的用户界面中。为此,我们将解决三个用户故事,以逐步构建所需的功能。
5.1 创建聊天环境(在 Jupyter Notebook 中)
我们从第 3 个用户故事开始:
“作为用户,我希望有一个直观的聊天界面,可以实时观看聊天机器人的对话展开。”(由 GPT-4 生成)
为了保持简单,我们选择 Jupyter 小部件,因为它们允许在 Jupyter Notebook 中快速构建整个聊天环境。
首先,我们设置显示对话的布局:
import ipywidgets as widgets
from IPython.display import display
# Create button
bot_ask = widgets.Button(description="Journalist Bot ask")
# Chat history
chat_log = widgets.HTML(
value='',
placeholder='',
description='',
)
# Attach callbacks
bot_ask.on_click(bot_ask_clicked)
# Arrange widgets layout
first_row = widgets.HBox([bot_ask])
# Display the UI
display(chat_log, widgets.VBox([first_row]))
我们创建了一个按钮(bot_ask),当用户点击它时,会调用一个回调函数bot_ask_clicked,并生成一轮记者和作者机器人之间的对话。之后,我们使用 HTML 小部件在笔记本中以 HTML 内容的形式显示对话。
回调函数bot_ask_clicked定义如下。除了显示记者机器人的问题和作者机器人的回答外,我们还指明了相关源文本的位置(即页面编号)。这是可能的,因为作者机器人的step()方法还返回source变量,这是一个包含页面内容及其相关元数据的 LangChain Document对象列表。
def bot_ask_clicked(b):
if chat_log.value == '':
# Starting conversation
bot_question = journalist.step("Start the conversation")
line_breaker = ""
else:
# Ongoing conversation
bot_question = journalist.step(chat_log.value.split("<br><br>")[-1])
line_breaker = "<br><br>"
# Journalist question
chat_log.value += line_breaker + "<b style='color:blue'>👨🏫 Journalist Bot:</b> " + bot_question
# Author bot answers
response, source = author.step(bot_question)
# Author answer with source
page_numbers = [str(src.metadata['page']+1) for src in source]
unique_page_numbers = list(set(page_numbers))
chat_log.value += "<br><b style='color:green'>👩🎓 Author Bot:</b> " + response + "<br>"
chat_log.value += "(For details, please check the highlighted text on page(s): " + ', '.join(unique_page_numbers) + ")"
综合考虑,我们得到了以下界面:

聊天界面。(作者提供的图片)
5.2 实现 PDF 高亮功能
在我们当前的 UI 中,我们仅指明了作者机器人查找记者机器人问题答案的页面。理想情况下,用户希望在原始 PDF 中突出显示相关文本,以便快速参考。这是第 4 个用户故事的动机:
“作为用户,我希望根据聊天机器人的讨论,在原始研究论文中突出显示相应的部分。这将帮助我快速定位在对话过程中讨论的信息来源。”(由 GPT-4 生成)
为了实现这一目标,我们使用了 PyMuPDF 库来搜索相关文本并执行文本高亮:
import fitz
def highlight_PDF(file_path, phrases, output_path):
"""Search and highlight given texts in PDF.
Args:
--------
file_path: PDF file path
phrases: a list of texts (in string)
output_path: save and output PDF
"""
# Open PDF
doc = fitz.open(file_path)
# Search the doc
for page in doc:
for phrase in phrases:
text_instances = page.search_for(phrase)
# Highlight texts
for inst in text_instances:
highlight = page.add_highlight_annot(inst)
# Output PDF
doc.save(output_path, garbage=4)
在上述代码中,phrases是一个字符串列表,每个字符串表示作者机器人用来生成回答的源文本之一。为了高亮文本,代码首先遍历 PDF 的每一页,查找该页是否包含phrase。一旦找到短语,它将在原始 PDF 中进行高亮显示。
为了将此高亮功能集成到我们之前开发的聊天 UI 中,我们首先需要更新回调函数:
def create_bot_ask_callback(title):
def bot_ask_clicked(b):
if chat_log.value == '':
# Starting conversation
bot_question = journalist.step("Start the conversation")
line_breaker = ""
else:
# Ongoing conversation
bot_question = journalist.step(chat_log.value.split("<br><br>")[-1])
line_breaker = "<br><br>"
chat_log.value += line_breaker + "<b style='color:blue'>👨🏫 Journalist Bot:</b> " + bot_question
# Author bot answers
response, source = author.step(bot_question)
##### NEW: Highlight relevant text in PDF
phrases = [src.page_content for src in source]
paper_path = "../Papers/"+title+".pdf"
highlight_PDF(paper_path, phrases, 'highlighted.pdf')
##### NEW
page_numbers = [str(src.metadata['page']+1) for src in source]
unique_page_numbers = list(set(page_numbers))
chat_log.value += "<br><b style='color:green'>👩🎓 Author Bot:</b> " + response + "<br>"
chat_log.value += "(For details, please check the highlighted text on page(s): " + ', '.join(unique_page_numbers) + ")"
return bot_ask_clicked
尽管我们的 UI 外观保持不变:

在底层,我们将有一个新的 PDF 文件,其中相关文本(在第 1 和第 10 页)被适当地高亮显示:

5.3 允许用户输入问题
到目前为止,这两个机器人的对话都是自主的。理想情况下,如果用户觉得合适,也应该能够提出自己的问题。这正是我们要解决的第 5 个用户故事:
“作为用户,我希望能够在聊天机器人的对话中进行干预并提出自己的问题,这样我可以引导对话并从论文中提取我需要的信息。”(由 GPT-4 生成)
为了实现这个目标,我们可以添加另一个按钮,让用户决定是否由记者机器人或用户发起新一轮的交流:
# Create "user ask" button
user_ask = widgets.Button(description="User ask")
# Define callback
def create_user_ask_callback(title):
def user_ask_clicked(b):
chat_log.value += "<br><br><b style='color:purple'>🙋♂️You:</b> " + user_input.value
# Author bot answers
response, source = author.step(user_input.value)
# Highlight relevant text in PDF
phrases = [src.page_content for src in source]
paper_path = "../Papers/"+title+".pdf"
highlight_PDF(paper_path, phrases, 'highlighted.pdf')
page_numbers = [str(src.metadata['page']+1) for src in source]
unique_page_numbers = list(set(page_numbers))
chat_log.value += "<br><b style='color:green'>👩🎓 Author Bot:</b> " + response + "<br>"
chat_log.value += "(For details, please check the highlighted text on page(s): " + ', '.join(unique_page_numbers) + ")"
# Inform journalist bot about the asked questions
journalist.memory.chat_memory.add_user_message(user_input.value)
# Clear user input
user_input.value = ""
return user_ask_clicked
上述回调函数本质上与定义记者-作者互动的回调函数相同。唯一的区别是“问题”将由用户直接输入。此外,为了保持采访逻辑的一致性,我们将用户问题附加到记者机器人的记忆中,就像用户提出的问题是由记者机器人提出的一样。
我们相应地更新了主要的用户界面逻辑:
# Chat history
chat_log = widgets.HTML(
value='',
placeholder='',
description='',
)
# User input question
user_input = widgets.Text(
value='',
placeholder='Question',
description='',
disabled=False,
layout=widgets.Layout(width="60%")
)
# Attach callbacks
bot_ask.on_click(create_bot_ask_callback(paper))
user_ask.on_click(create_user_ask_callback(paper))
# Arrange the widgets
first_row = widgets.HBox([bot_ask])
second_row = widgets.HBox([user_ask, user_input])
# Display the UI
display(chat_log, widgets.VBox([first_row, second_row]))
这是我们得到的结果,用户可以输入自己的问题,并由作者机器人进行回答:

除了让记者机器人提问外,用户还有机会提出自己的问题。(图片由作者提供)
5.4 允许下载生成的脚本
一切顺利!作为最后一个要实现的功能,我们希望能够将对话历史保存到磁盘以供以后参考。这是第 6 个用户故事的目标:
“作为用户,我希望能够下载聊天机器人对话的记录。这将让我离线查看关键点或与同事分享信息。”(由 GPT-4 生成)
为此,我们添加了一个新的下载脚本按钮,并将回调函数附加到该按钮。在这个回调函数中,我们使用了 PDFDocument 将对话脚本转换为 PDF 文件:
from pdfdocument.document import PDFDocument
download = widgets.Button(description="Download paper summary",
layout=widgets.Layout(width='auto'))
def create_download_callback(title):
def download_clicked(b):
pdf = PDFDocument('paper_summary.pdf')
pdf.init_report()
# Remove HTML tags
chat_history = re.sub('<.*?>', '', chat_log.value)
# Remove emojis
chat_history = chat_history.replace('👨🏫', '')
chat_history = chat_history.replace('👩🎓', '')
chat_history = chat_history.replace('🙋♂️', '')
# Add line breaks
chat_history = chat_history.replace('Journalist Bot:', '\n\n\nJournalist: ')
chat_history = chat_history.replace('Author Bot:', '\n\nAuthor: ')
chat_history = chat_history.replace('You:', '\n\n\nYou: ')
pdf.h2("Paper Summary: " + title)
pdf.p(chat_history)
pdf.generate()
# Download PDF
print('PDF generated successfully in the local folder!')
return download_clicked
我们相应地更新了主要的用户界面逻辑:
# Chat history
chat_log = widgets.HTML(
value='',
placeholder='',
description='',
)
# User input question
user_input = widgets.Text(
value='',
placeholder='Question',
description='',
disabled=False,
layout=widgets.Layout(width="60%")
)
# Attach callbacks
bot_ask.on_click(create_bot_ask_callback(paper))
user_ask.on_click(create_user_ask_callback(paper))
download.on_click(create_download_callback(paper))
# Arrange the widgets
first_row = widgets.HBox([bot_ask])
second_row = widgets.HBox([user_ask, user_input])
third_row = widgets.HBox([download])
# Display the UI
display(chat_log, widgets.VBox([first_row, second_row, third_row]))
现在,用户界面中出现了一个下载按钮。当用户点击它时,会自动生成并下载一份论文总结的 PDF 文件到本地文件夹:

用户现在可以选择下载生成对话的脚本。(图片由作者提供)
6. 冲刺评审:展示演示!
现在是时候展示我们的辛勤工作💪
在这个演示中,我们展示了我们开发的双聊天机器人系统的全部功能:
-
这两个机器人可以自主进行采访,目的是消化论文的主要观点。
-
用户也可以进入对话并提出感兴趣的问题。
-
生成的答案的相关文本会在原始 PDF 中自动高亮显示。
-
对话历史可以下载到本地文件夹。
我们成功解决了所有用户故事,干得好🎉 现在冲刺评审已经结束,是时候进行一些回顾了。
7. 冲刺回顾
在这个项目中,我们专注于解决高效消化复杂研究论文的问题。为此,我们开发了一个双聊天机器人系统,其中一个机器人扮演“记者”,另一个机器人扮演“作者”,两个机器人进行采访。这样,记者机器人可以代表用户查询论文的关键点。这是有益的,因为它消除了用户自行设计问题的需求——这一活动在处理不熟悉的主题时可能既具有挑战性又耗时。
设计的双聊天机器人方法的成功关键在于记者机器人引导采访并生成有见地且相关的问题的能力。在当前的实现中,我们使用了 GPT-3.5-Turbo 作为主要的语言模型。为了进一步提升用户体验,可能需要使用 GPT-4 来增强记者机器人的推理能力。
另外重要的是,记者机器人需要能够解释和理解在相关研究领域中使用的技术术语和概念。除了使用先进的语言模型,针对目标领域的研究论文对现有语言模型进行微调可能是一种有前景的策略。
展望未来,我们可以扩展我们当前的项目,有几种可能性:
-
更好的 UI 设计。 为了简单起见,我们使用了 Jupyter Notebook 来展示双聊天机器人系统的主要理念。我们可以使用更复杂的库(例如 Streamlit)来构建更用户友好、互动性更强的 UI。
-
多模态能力。 例如,可以使用文本转语音(TTS)技术为生成的脚本创建音频。这对用户很有帮助,因为他们可以在通勤、锻炼或其他阅读不便的活动中继续消耗内容。
-
访问外部数据库。 如果双聊天机器人系统能够访问更大的外部研究论文库,那将是非常棒的,这样作者机器人可以提供与领域内最新发展的比较分析,从而综合多个论文的见解。
-
生成文献综述。 由于生成的访谈脚本可以作为比论文摘要更丰富的论文精华,我们可以首先收集特定研究领域中各种论文的脚本,然后请求一个单独的语言模型基于分析这些积累的访谈脚本生成该领域的综合评述。这一功能对于研究人员在启动新的研究项目或文献综述论文时尤其有价值。
我们的冲刺真是富有成效!如果你觉得我的内容有用,可以在这里请我喝杯咖啡🤗 非常感谢你的支持!和往常一样,你可以在这里找到包含完整代码的配套笔记本💻 期待与你分享更多令人兴奋的 LLM 项目。敬请关注!
如何为 2v2 游戏创建基于数据的 Elo 评级系统
将数学放在桌面上:从算法到桌上足球的疯狂,寻找终极办公室冠军。
·
关注 发布于 Towards Data Science ·19 分钟阅读·2023 年 9 月 6 日
--
图片由 Pascal Swier 提供,发布在 Unsplash
你好,欢迎!
我的名字是 Lazare,我刚完成了我的第二个学士学位,专业是商业数据分析。本文基于我为学士论文所做的工作。
从友谊赛到激烈的竞争,桌上足球在企业文化中找到了自己的位置,为团队提供了一种独特的连接和竞争方式。
本文探讨了基于 Elo 的 2v2 评分系统的数学原理,该系统可以应用于桌上足球或任何其他 2v2 游戏。它还考察了支持数据处理的架构,并展示了创建一个实时排名和数据分析的网络应用程序,使用 Python 编写。
Elo 排名
Elo 评级系统是一种用于确定玩家在零和游戏中相对技能水平的方法。最初为国际象棋开发,但现在已被应用于各种其他体育项目,如棒球、篮球、各种棋盘游戏和电子竞技。
这个系统的一个著名例子是国际象棋,其中 Elo 评级系统用于排名全球玩家。Magnus Carlsen,亦称为“国际象棋的莫扎特”,在 2023 年以 2,853 的评级保持全球最高 Elo 评级,展示了他在比赛中的非凡技能。
Elo 评级公式分为两部分:首先,它计算给定玩家组的预期结果,然后根据比赛结果和预期结果确定评级调整。
预期结果计算
考虑以下国际象棋中的例子,其中玩家 A 和玩家 B 的评级分别为 R𝖠和R𝖡。玩家 A 对阵玩家 B 的预期得分方程如下:

Elo 算法使用一个变量,可以调整以控制胜利概率如何受到玩家评级的影响。在这个例子中,设定为 400,这在大多数体育项目中,包括国际象棋,是典型的。
现在让我们来看一个更实际的例子,其中玩家 A 的评级为 1,500,而玩家 B 的评级为 1,200。
上述相同的方程可以计算玩家 A 对阵玩家 B 的期望得分:

通过这个计算,我们知道玩家 A 对阵玩家 B 的胜率为 84.9%。
要找到玩家 B 对阵玩家 A 的估计胜率,使用相同的公式,但评级顺序会被颠倒:

玩家 A 获胜的概率与玩家 B 获胜的概率之和等于 1(0.849 + 0.151 = 1)。在这种情况下,玩家 A 的胜率为 84.9%,而玩家 B 的胜率仅为 15.1%。
评级计算
胜者和败者之间的评级差异决定了每场比赛后赢得或失去的总积分。
-
如果一名具有更高 Elo 评级的玩家获胜,他们将为胜利获得更少的积分,而对手将因失败而损失少量积分。
-
相比之下,如果低排名玩家获胜,这一成就被认为更为重要,因此奖励也更大,而高排名对手会因此受到相应的惩罚。
计算玩家 A 与玩家 B 对战的新评级的公式如下:

在这个公式中,(S𝖠 — E𝖠)代表玩家 A 实际得分与预期得分之间的差异。附加变量K大致确定了单场比赛后玩家评级可以变化的程度。在象棋中,这个变量设为 32。
如果玩家 A 获胜,则实际得分为 1,在这种情况下,将高于预期得分 0.849,从而产生正差异。
这表明玩家 A 的表现比最初预期的要好。因此,Elo 评级系统会重新校准两位玩家的评级:
-
玩家 A 的评级将因胜利而增加
-
玩家 B 的评级将因失败而减少
再次地,这个相同的公式可以计算玩家 A 和玩家 B 的新评级:

总之,Elo 评级系统提供了一种强大且高效的方法来动态而公平地评估和比较玩家的技能。它在每场比赛后持续更新玩家的评级,考虑到两位对手之间的技能差异。
这种方法奖励冒险,因为与高评级玩家对战获胜会显著提高玩家的评级,如下表所示:

图 I :象棋中的 Elo 系统示例 | 作者提供的表格
另一方面,如果一名高评级玩家违背了他们的胜率,与低评级玩家对战并输掉比赛,他们的评级将受到显著影响:他们将失去更多积分,而对手将获得更多积分。
总之,当一名玩家赢得比赛时,他们的获胜概率越低,他们能够赢得的积分就越高。
在当前状态下,这个最初为象棋设计的评级公式尚未完全适应桌上足球。
实际上,桌上足球比象棋有更多变量,例如:
-
它是一个四人游戏,由两个队伍组成(2v2)
-
每个队员可以对他们的队友产生积极或消极的影响
-
与棋类游戏中的二元结果不同,桌上足球中胜利或失败的规模可以根据队伍的得分有显著变化。
评级算法探索
这里的重点是将 Elo 评级系统调整为满足桌上足球比赛的独特要求,这涉及四名玩家分成两个队伍。
胜率
要开始计算新的玩家评级,需要建立一个精细的公式来确定涉及四名玩家分成两队的比赛的预期结果。
为了说明这一点,考虑一个假设的四人桌上足球比赛情境:玩家 1、玩家 2、玩家 3 和玩家 4,每个人的评分代表了他们的技能水平。

图 II:四名玩家进行桌上足球比赛的情境 | 表格由作者提供
在修订后的 Elo 评分系统中,计算团队 1 对抗团队 2 的预期得分,需要确定游戏中每位玩家的预期得分。
玩家 1 的预期评分,记作E𝖯𝟣,可以通过使用 Elo 评分公式计算每个对手评分的平均值来得出:

经过广泛测试后,决定将预期得分公式中用于除以评分差异的变量设为 500,而不是传统的棋类使用的 400。这一增加的值意味着玩家的评分对其预期得分的影响会更小。
这一调整的主要原因在于,与棋类不同,桌上足球中有少许运气成分。通过使用 500 的值,比赛结果可以更准确地预测,并且可以开发出一个可靠的评分系统。
计算玩家 2 的预期得分,记作E𝖯𝟤,对抗玩家 3 和玩家 4,可以采用与玩家 1 相同的方法。
然后,团队的预期得分记作E𝖳𝟣,可以通过计算E𝖯𝟣和E𝖯𝟤的平均值得出:

一旦计算出每位玩家的预期得分,就可以用这些得分来计算比赛结果。预期得分最高的团队更有可能获胜。通过平均每个团队成员的预期得分,团队内部技能差异的问题可以得到解决!
下表显示了玩家 1 和玩家 2 对抗玩家 3 和玩家 4 的预期得分。
-
P1 对 P3 和 P4 的预期得分分别为 0.091 和 0.201,对应 14.6%的胜率。
-
P2 对 P3 和 P4 的预期得分分别为 0.201 和 0.387,总体获胜概率为 29.4%。
-
对于 P1,与像 P2 这样更强的玩家搭档可以提高他们的整体获胜机会,如 22%所示。

图 III:基于图 II 所示情境的预期得分 | 表格由作者提供
如果 P1 和 P2 的团队获胜,P1 获得的积分将低于其个人预期得分,因为排名较高的 P2 也参与了胜利,降低了 P1 的总体获胜概率。
另一方面,P2 由于有一名排名较低的队友而获得更多积分。如果获胜,P2 会因冒险而获得奖励,而 P1 则获得较少的积分,因为假设 P2 对胜利的贡献更大,反之亦然,如果他们失败。
评分参数
现在已经确定了四人比赛的预期结果,这些信息可以纳入一个新的公式,该公式考虑了影响比赛和球员评分的多个变量。
如前所述,K 值可以调整以更好地适应评分系统的需要。这个新公式考虑了每个球员参加的比赛数量,反映了他们的资历以及比赛结果。
例如,在 2014 年世界杯半决赛中,德国以 7–1 战胜了巴西。这是世界杯历史上最令人震惊和羞辱的结果之一,因为巴西是东道主,自 1975 年以来在主场从未输过比赛。
如果将评分系统应用于这场比赛,我们预计德国会获得大量积分,而巴西会损失大量积分,反映出两队在表现和技能水平上的差异。
K 值 在这种情况下,K𝟣表示球员 1 的 K 评分,决定了球员在一场比赛后评分变化的幅度。这个修正后的 K 值考虑了球员参与的比赛数量,以平衡每场比赛对其评分的影响。经过多次测试,制定了计算每个球员 K 值的公式。
对于球员 1,这可以表示为:

这个 K 值的公式旨在对新球员的评分产生更大的影响,同时为经验丰富的球员提供稳定性,减少评分波动。具体来说,在进行 300 场比赛后,球员的评分将更具代表性地反映其技能水平。

图 IV:K 值随时间变化 | 作者绘制的图表
图 IV 显示了比赛场数对 K 值的影响。从 50 场开始,该图显示随着比赛场数的增加,K 值逐渐降低,在 300 场比赛后降至一半的 25。这确保了随着经验的增加,每场比赛对球员评分的影响会减少。
点因子 为了考虑每支球队得分的情况,方程中引入了一个新的变量,称为“点因子”。该因子将每个球员的K参数相乘,并基于两队得分的绝对差值。比赛的影响必须更大,当一队以大比分获胜,即取得压倒性胜利时。
计算点因子的公式如下:

该公式取两队得分的绝对差值,加 1,并计算结果的以 10 为底的对数。然后将该值立方,并加上 2,得到点因子的最终值。

图 V:点因子 | 作者绘制的图表
最终评分计算
在调整了所有必要的变量之后,开发了一种改进的公式来计算每个参与游戏的玩家的新排名。
现在,每个玩家的评级考虑了他们的先前评级、对手的评级、队友的影响、他们的比赛历史和比赛的得分。这个公式确保每个玩家根据他们的真实表现获得奖励,同时考虑了每场比赛的公平性。
以之前的例子为基础,玩家 A 的新排名公式如下:

这个改进的公式根据玩家的实际表现给予奖励,鼓励冒险,并为新玩家和经验丰富的玩家提供了一个更平衡的评级系统。
现在我们有了 Elo 算法,我们可以继续进行数据库建模。
数据库设计与建模
提议的数据库 模型采用了关系型方法,通过使用主键(PKs)和外键(FKs)将数据组织成相互关联的表。这种结构化的组织有助于数据管理和分析,使 PostgreSQL 成为合适的数据库管理系统选择。PKs 和 FKs 帮助维护数据一致性并减少数据库中的冗余。

图 VI:数据库的模型图 | 作者提供的图像
在这个数据库模型中,表之间存在两种关系:一对多 和 多对多。
‘Player’ 表和‘Match’ 表之间的关系是多对多的,因为一个玩家可以参与多场比赛,多个玩家可以参与一场比赛。一个名为‘PlayerMatch’的连接表桥接了这一关系,包含两个外键:‘player_id’(引用参与的玩家)和‘match_id’(引用对应的比赛)。
这种结构确保了玩家和比赛的准确关联,如下面的代码所示:
CREATE TABLE PlayerMatch (
player_match_id serial PRIMARY KEY,
player_id INT NOT NULL REFERENCES Player(player_id),
match_id INT NOT NULL REFERENCES Match(match_id)
);
类似的逻辑适用于‘TeamMatch’表,该表作为‘Match’和‘Team’表之间的连接点,允许多个队伍参加一场比赛,并且一场比赛涉及多个队伍。
为了简化长期的排名分析,设计了‘PlayerRating’和‘TeamRating’两个表。这些表分别通过‘player_match_id’和‘team_match_id’与‘PlayerMatch’和‘TeamMatch’表连接。
数据完整性
除了使用 PKs 和 FKs 之外,这个数据库模型还使用了适当的数据类型和 CHECK 约束以保证数据完整性:
-
‘winning_team_score’ 和 ‘losing_team_score’ 列在‘Match’表中是整数,防止非数字输入。
-
CHECK 约束确保‘winning_team_score’恰好为 11。
-
CHECK 约束确保 ‘losing_team_score’ 在 0 到 10 之间,符合游戏规则。
如下面的代码块所示,为每个主键使用序列已在数据库创建中实现,以便于数据录入。这种自动化简化了后续使用 Python 循环进行数据录入过程的整体流程。
CREATE SEQUENCE player_id_seq START 1;
CREATE SEQUENCE team_id_seq START 1;
CREATE SEQUENCE match_id_seq START 1;
CREATE SEQUENCE player_match_id_seq START 1;
CREATE SEQUENCE player_rating_id_seq START 1;
CREATE SEQUENCE team_match_id_seq START 1;
CREATE SEQUENCE team_rating_id_seq START 1;
数据处理
主要挑战在于找到一种处理比赛数据的顺序,以便从正在处理并插入数据库的初始数据中检索 ID。
这些特定的 ID 随后可以作为外键来管理其余的数据,在过程中创建必要的关系。换句话说,第一步是从原始数据中识别并存储特定数据(ID),然后利用这些 ID 作为桥梁来连接和处理其余的数据。
数据按步骤处理,使用日益复杂的 Python 循环。每个新条目都被分配一个由表的序列生成的唯一主键。
-
第一步是处理单个玩家并获取他们的 ID。
-
接下来,使用玩家 ID 处理团队。对于比赛中的每对独特的玩家,‘Team’ 表中创建了一个条目(FK players)。
-
随后,比赛使用胜利和失败的团队 ID 进行处理。处理完比赛后,通过检索相应的比赛、玩家和团队 ID 处理了 ‘PlayerMatch’ 和 ‘TeamMatch’ 表。
-
一旦所有必要的数据处理完成,‘PlayerMatch’ 和 ‘TeamMatch’ 的 ID,以及 ‘match’ 时间戳被用于 ‘PlayerRating’ 和 ‘TeamRating’ 表中,以跟踪评级随时间的变化。
Web 应用程序开发
Web 应用程序的目标是允许用户输入比赛结果、验证数据,并直接与数据库进行交互。这确保了数据是最新的并且实时提供,使用户始终能够访问排名或可视化他们的指标。
此外,我还希望使 Web 应用程序适应移动设备,因为谁愿意拖着一台笔记本电脑来玩桌上足球?那既不实用也不有趣。
技术栈
后台 在比较了 Django 和 Flask 这两个流行的 Python Web 框架后,选择了 Flask,因为它对初学者更友好。Flask Web 框架用于处理用户请求、处理数据和与 PostgreSQL 数据库交互。
前端 前端由静态 HTML 和 CSS 文件组成,这些文件定义了 Web 应用程序的结构和样式。JavaScript 用于表单验证和处理用户交互。这确保了用户提交的数据在发送到后台之前是一致和准确的。
数据可视化 在数据可视化方面,最大的挑战是拥有最新的数据。为克服这一限制,数据可视化层使用了Plotly,这是一个 Python 库,用于生成互动图表和图形,以可视化玩家的评分变化。此组件从后端接收数据,处理数据,并以用户友好的格式呈现给用户。
数据库 PostgreSQL 被用于本地开发环境以及通过 Heroku 在 AWS 上的生产环境。Heroku 提供自动数据库备份,确保数据得到保护,并且在必要时可以轻松恢复。
UI/UX 研究
对于 UI/UX 设计,灵感来自于 Spotify 的现代网页设计和新的 Bing 搜索引擎。目标是创造一个熟悉且直观的用户体验。

图 VII: 应用程序的模拟图 | 作者提供的图像
应用程序功能
让我们通过一个具体的场景来深入了解应用程序的功能。团队 1(Matthieu 和 Gabriel)想与团队 2(Wissam 和 Malik)对战。所有玩家都有不同的评分,代表他们的技能水平,如下所示。

计算概率
玩家在任何比赛之前最想做的第一件事是计算他们的获胜概率。
为此,“计算概率”视图允许用户通过下拉菜单选择四名玩家,并生成所选团队的获胜概率。

图 VIII: 计算概率 | 作者提供的图像
这一功能主要在游戏之前使用,以验证比赛是否平衡,并告知玩家他们的获胜概率。例如,团队 1 的获胜概率为 64.19%,而团队 2 的获胜概率为 35.81%。这一视图告知每个玩家赌注和承担的风险。
一旦表单提交,应用程序仅计算算法的第一部分,即在给定四个选定玩家的情况下计算游戏的预期结果。
上传游戏
“上传游戏”视图作为应用程序的主页。它旨在为用户提供方便,使他们在打开应用程序时能够立即上传游戏。

图 IX: 上传游戏 & 匹配上传 | 作者提供的图像
在表单提交之前,应用程序使用 JavaScript 进行数据验证,以确保:
-
选择了四名不同的玩家
-
分数为非负整数
-
只有一个得分恰好为 11 的获胜团队,不允许平局
当验证成功时,应用程序使用完整算法处理数据,更新数据库中的相应表,并向用户确认他们的上传。
“比赛上传”视图旨在向用户展示每场比赛对其个人评分的影响。它计算了比赛上传前后玩家评分之间的差异。
如上所示,每场游戏对每个玩家评分的影响并不相同。这是因为算法对每个玩家的个体参数不同:他们的预期得分、游戏数量、队友和对方队伍。
Elo 排名
“玩家排名”视图允许用户访问实时的月度排名,并与其他玩家进行比较。用户可以看到他们的评分、他们在整个月份中参加的游戏数量以及他们参加的最后一场游戏,展示他们的最新评分。

图 X: 玩家排名 | 作者提供的图片
一旦访问“玩家排名”视图或提交新时间段,应用程序会使用 CTE 方法查询数据库。
这涉及到连接所有必要的表格并显示最新的排名更新,使用时间段选择器来筛选查询:
def get_latest_player_ratings(month=None, year=None):
now = datetime.now()
default_month = now.month
default_year = now.year
selected_year = int(year) if year else default_year
selected_month = int(month) if month else default_month
start_date = f'{selected_year}-{selected_month:02d}-01 00:00:00'
end_date = f'{selected_year}-{selected_month:02d}-{get_last_day_of_month(selected_month, selected_year):02d} 23:59:59'
query = '''
WITH max_player_rating_timestamp AS (
SELECT
pm.player_id,
MAX(pr.player_rating_timestamp) as max_timestamp
FROM PlayerMatch pm
JOIN PlayerRating pr ON pm.player_match_id = pr.player_match_id
WHERE pr.player_rating_timestamp BETWEEN %s AND %s
GROUP BY pm.player_id
),
filtered_player_match AS (
SELECT
pm.player_id,
pm.match_id
FROM PlayerMatch pm
JOIN max_player_rating_timestamp mprt ON pm.player_id = mprt.player_id
),
filtered_matches AS (
SELECT match_id
FROM Match
WHERE match_timestamp BETWEEN %s AND %s
)
SELECT
CONCAT(p.first_name, '.', SUBSTRING(p.last_name FROM 1 FOR 1)) as player_name,
pr.rating,
COUNT(DISTINCT fpm.match_id) as num_matches,
pr.player_rating_timestamp
FROM Player p
JOIN max_player_rating_timestamp mprt ON p.player_id = mprt.player_id
JOIN PlayerMatch pm ON p.player_id = pm.player_id
JOIN PlayerRating pr ON pm.player_match_id = pr.player_match_id
AND pr.player_rating_timestamp = mprt.max_timestamp
JOIN filtered_player_match fpm ON p.player_id = fpm.player_id
JOIN filtered_matches fm ON fpm.match_id = fm.match_id
GROUP BY p.player_id, pr.rating, pr.player_rating_timestamp
ORDER BY pr.rating DESC;
'''
数据可视化
开发这个综合解决方案的主要目标是为用户提供一个实时排名系统,作为每位玩家表现的可视化表示。
尽管像 PowerBI 和 Qlik 这样强大的数据可视化工具可供使用,但选择了一个完全兼容移动设备的解决方案,使用户能够在其设备上实时获取洞察,而无需支付许可费用。
实现这一点采用了两种方法:
-
首先,使用了 Dash Plotly,一个 Python 框架,允许开发者在 Flask 应用程序的基础上构建互动的、数据驱动的应用程序。
-
其次,使用了各种 SQL 查询和静态 HTML 页面来从数据库中提取信息并显示,确保用户始终可以访问实时数据。
排名演变
这种可视化方式让玩家能够观察每场比赛对其排名的影响,并识别出更广泛的趋势。例如,他们可以准确地看到何时被其他人超越,或者连续的胜利或失败的影响。

图 XI: 排名演变 | 作者提供的图片
当访问“排名演变”视图时,应用程序会对每个选定的玩家在数据库中进行查询,检索每场比赛日的最新排名更新:
SELECT DISTINCT ON (DATE_TRUNC('day', m.match_timestamp))
DATE_TRUNC('day', m.match_timestamp) AS day_start,
CASE WHEN p.first_name = '{player}' THEN pr.rating ELSE NULL END AS rating
FROM PlayerMatch pm
JOIN Player p ON pm.player_id = p.player_id
JOIN PlayerRating pr ON pm.player_match_id = pr.player_match_id
JOIN Match m ON pm.match_id = m.match_id
WHERE p.first_name = '{player}'
ORDER BY DATE_TRUNC('day', m.match_timestamp) DESC, m.match_timestamp DESC
检索到的数据表会被转换成折线图,列会被转换为轴,使用 Dash 进行展示。
为了减少数据库负载并简化图表中的数据展示,每天只显示最新的评分更新。
玩家指标
受到 Spotify Wrapped 的启发,目的是提供来自持续数据收集的洞察。虽然有巨大的潜力可视化玩家洞察,但重点是突出个体表现和玩家之间的联系的指标。

图 XII:玩家指标 | 图片来源于作者
这些指标分为三类颜色编码的类别:搭档、比赛和对手,每个指标都附有标题、值和详细的子测量。
游戏指标 这些指标集中在屏幕中央,以蓝色显示以保持中立。它们包括自数据收集开始以来的总比赛数量。
搭档指标 搭档指标显示在屏幕左侧。由于其积极的含义,它们以绿色显示。
-
顶部的框突出了所选玩家最常一起打比赛的主要搭档。
-
第二个指标确定了玩家的最佳搭档。这是由最高的胜率定义的。
-
此类别中的第三个指标是所选玩家最差的搭档。这是根据最低的胜率(或最高的败率)计算的。
对手指标 对手指标以红色显示以表示对抗。对手指标代表了玩家之间的竞争关系。
-
顶部的框显示了最常见的对手,附有一个子指标,指示一起打过的比赛数量,类似于搭档指标。
-
第二个指标,“最容易的对手”,表示玩家胜率最高的对手。这表明对手较弱。
-
最终指标是所选玩家胜率最低的对手。此指标表示最困难的对手。
结论
在写这篇文章时,应用程序已经使用了 6 个月,这些是迄今为止的结果:
-
这个基于 Elo 系统的排名系统可以预测比赛结果,并根据玩家的实际表现准确地排名。
-
玩家变得更加竞争激烈,因为他们通过数据可视化越来越了解自己的表现。
-
由于改进的公式奖励冒险的玩家,玩家变得更加包容。那些通常不会一起打球的玩家现在有了配对的动力。
通过采用数据驱动的策略,本项目突显了数据的深远影响和重要性。
超越简单的玩家表现分析,本项目发起了玩家在踢足球游戏中与其他玩家及新手互动方式的变革。数据的力量真正培养了一个更具包容性和竞争性的环境。
感谢你读到这里!希望你觉得这篇文章有用。如果你有兴趣阅读完整的论文,可以在 这里 找到。
此外,所有代码都可以在 Github 上找到。
欢迎在评论中分享你的想法 😃
使用 Spark 和 Plotly Dash 开发互动且富有洞察力的仪表板
用于 Python Web 应用的互动大型数据可视化
·
关注 发布于 数据科学的未来 ·11 分钟阅读·2023 年 6 月 21 日
--
作者照片
1. 介绍
云数据湖被企业组织广泛采用作为一种可扩展且低成本的所有类型(结构化和非结构化)数据的存储库。在高效分析大规模数据集以获得数据驱动决策所需的有意义见解方面存在许多挑战。一个挑战是数据集的大小往往太大,无法容纳在单台机器上。通常需要一组服务器来处理大数据集。另一个挑战是如何在服务器上将数据分析结果轻松且经济高效地与相关客户/股东共享。
本文使用与 [1] 相同的开源数据集,展示了一个基于开源的 Web 应用框架,用于使用 Spark [2][3] 和 Plotly Dash[4] 开发交互式和有洞察力的仪表盘。该框架使我们能够在服务器上分析和可视化大规模数据集,并将数据分析和可视化的结果作为仪表盘在任何地方共享。
如图 1 所示,新 Web 应用框架由三个主要组件组成:
-
Spark SQL 服务(例如 DataFrame)用于分布式数据处理(见第二部分)。
-
Plotly 绘图服务用于创建数据可视化图表作为仪表盘(见第三部分)。
-
Dash Web 服务用于在服务器端 Plotly 绘图服务与仪表盘客户端之间进行交互(见第四部分)。

图 1: 高级应用框架架构。
2. Spark SQL 服务用于分布式数据处理
如 [2] 中所述,PySpark(Spark 的 Python API)可以很容易地用于从云数据湖(如 AWS S3)读取 csv 文件。为了简便起见,本文假设本地计算机上有一个数据集 csv 文件 train_data.csv [1],而不失一般性。
以下代码用于将 csv 文件加载到内存中作为 Spark SQL DataFrame:
import pyspark
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName('hospital-stay').getOrCreate()
spk_df = spark.read.csv('./data/train_data.csv',
header = True, inferSchema = True)
数据加载后,可以创建一个全局临时视图,以便动态数据查询。
spk_df.createOrReplaceTempView("dataset_view")
一旦创建了数据集视图,我们可以像从数据库中查询常见数据一样使用 Spark SQL 查询数据。例如,以下代码查询所有 dataset_view 中年龄在 [21, 30] 范围内的行。
age = "21-30"
sdf = spark.sql(f"SELECT * FROM dataset_view WHERE Age=='{age}'")
为了使用 Plotly 从 Spark DataFrame sdf 创建数据可视化图表,我们必须将其转换为 Pandas DataFrame pdf,因为 Plotly 不直接支持 Spark DataFrame。
pdf = sdf.toPandas()
3. 使用 Plotly 创建数据可视化图表
Plotly 支持生成多种不同类型的图表。其中一些适用于从连续的数值特征创建图表,而另一些则适用于从离散的分类特征创建图表。
本文使用 Plotly Express 库创建以下常见图表用于演示。
-
数值特征图表: 散点图、直方图和折线图
-
分类特征图表: 柱状图、直方图、折线图和饼图
3.1 数字特征的图表
如前所述,常见的三种数字特征图表包括:
-
散点图
-
直方图
-
折线图
给定一对数字特征,散点图使用每对特征值作为坐标,在二维平面上绘制一个点。例如,下面的图展示了年龄在 21 到 30 岁之间的两个数字特征病人 ID和入院押金的散点图。特征入院类型用于颜色编码。

图 2: 一对数字特征的示例散点图。
假设仪表盘用户选择了年龄范围[21, 30],一对数字特征x = patientid和y = Admission_Deposit,以及颜色编码特征 = 入院类型,以下语句创建了上述散点图。
fig = px.scatter(dff, x = x, y = y, color = color_feature)
同样,以下语句用于创建相同数据的直方图:
fig = px.histogram(dff, x = x, y = y, color = color_feature)

图 3: 一对数字特征的示例直方图。
为了完整性,以下语句用于创建折线图。
fig = px.ine(dff, x = x, y = y, color = color_feature)

图 4: 一对数字特征的示例折线图。
尽管我们可以很容易地创建折线图,但如上图所示的折线图并没有揭示有用的洞察。折线图的良好使用是将其应用于以有意义的方式排序的数据集,例如按时间顺序排列的数据序列或按计数排序的特征值列表,如第 3.2 节所示。
3.2 分类特征的图表
本小节展示了四种常见的分类特征图表:
-
柱状图
-
直方图
-
折线图
-
饼图
假设仪表盘用户选择了年龄 = [21–30],分类特征 = 停留,颜色 = 紫色,图表样式 = 柱状,以下代码可以用来生成下面的柱状图。
dff = spark.sql(f"SELECT * FROM dataset_view WHERE Age=='{age}'").toPandas()
vc = dff[feature].value_counts()
fig = px.bar(vc, x = vc.index, y = vc.values)
我注意到直方图与柱状图在分类特征值计数上的结果相同。

图 5: 分类特征值计数的示例柱状图。
通过选择图表样式 = 折线,我们可以创建如下折线图:
fig = px.line(vc, x = vc.index, y = vc.values)

图 6: 分类特征值计数的示例折线图。
如前所述,折线图适合用于可视化分类特征的值计数。
以下代码用于创建同一分类特征停留的饼图。
fig = px.pie(vc, x = vc.index, y = vc.values)
该饼图使用自动颜色编码,而不是所选颜色紫色。

图 7: 分类特征值计数的示例饼图。
4. Dash 用于互动数据可视化
前一节描述了如何在 Spark 服务器集群上使用 Plotly Express 库创建不同类型图表的仪表板。本节展示了如何使用 Dash 与 Web 应用程序客户端共享仪表板,并允许客户端以交互方式使用仪表板来以各种方式可视化数据。
可以按照以下步骤开发一个 Web 应用程序的一页仪表板:
-
第 1 步:导入 Dash 库模块
-
第 2 步:创建 Dash 应用程序对象
-
第 3 步:定义 HTML 页面仪表板布局
-
第 4 步:定义回调函数(Web 服务端点)
-
第 5 步:启动服务器
4.1 导入 Dash 库模块
首先,按照如下导入 Plotly Dash 库模块,以便于本文的演示。
import plotly.express as px
from dash import Dash, dcc, html, callback, Input, Output
4.2 创建 Dash 应用程序对象
导入库模块后,下一步是创建 Dash 应用程序对象:
app = Dash(__name__)
4.3 定义仪表板布局
一旦创建了 Dash 应用程序对象,我们需要定义一个作为 HTML 页面的仪表板布局。
本文中的仪表板 HTML 页面分为两个部分:
-
第一部分:数值特征的可视化
-
第二部分:分类特征的可视化
仪表板布局第一部分的定义如下:
app.layout = html.Div([ # dashboard layout
html.Div([ # Part 1
html.Div([
html.Label(['Age:'], style={'font-weight': 'bold', "text-align": "center"}),
dcc.Dropdown(
['0-10', '11-20', '21-30', '31-40', '41-50',
'51-60', '61-70', '71-80', '81-90', '91-100',
'More than 100 Days'],
value='21-30',
id='numerical_age'
),
],
style={'width': '20%', 'display': 'inline-block'}),
html.Div([
html.Label(['Numerical Feature x:'], style={'font-weight': 'bold', "text-align": "center"}),
dcc.Dropdown(
['patientid',
'Hospital_code',
'City_Code_Hospital'],
value='patientid',
id='axis_x',
)
], style={'width': '20%', 'display': 'inline-block'}),
html.Div([
html.Label(['Numerical Feature y:'], style={'font-weight': 'bold', "text-align": "center"}),
dcc.Dropdown(
['Hospital_code',
'Admission_Deposit',
'Bed Grade',
'Available Extra Rooms in Hospital',
'Visitors with Patient',
'Bed Grade',
'City_Code_Patient'],
value='Admission_Deposit',
id='axis_y'
),
], style={'width': '20%', 'display': 'inline-block'}),
html.Div([
html.Label(['Color Feature:'], style={'font-weight': 'bold', "text-align": "center"}),
dcc.Dropdown(
['Severity of Illness',
'Stay',
'Department',
'Ward_Type',
'Ward_Facility_Code',
'Type of Admission',
'Hospital_region_code'],
value='Type of Admission',
id='color_feature'
),
], style={'width': '20%', 'display': 'inline-block'}),
html.Div([
html.Label(['Graph Style:'], style={'font-weight': 'bold', "text-align": "center"}),
dcc.Dropdown(
['scatter',
'histogram',
'line'],
value='histogram',
id='numerical_graph_style'
),
], style={'width': '20%', 'float': 'right', 'display': 'inline-block'})
],
style={
'padding': '10px 5px'
}),
html.Div([
dcc.Graph(id='numerical-graph-content')
]),
......
图 2、3 和 4 是通过使用仪表板布局第一部分创建的。
仪表板布局第二部分的定义如下:
......
html.Div([ # Part 2
html.Div([
html.Label(['Age:'], style={'font-weight': 'bold', "text-align": "center"}),
dcc.Dropdown(
['0-10', '11-20', '21-30', '31-40', '41-50',
'51-60', '61-70', '71-80', '81-90', '91-100',
'More than 100 Days'],
value='21-30',
id='categorical_age'
),
],
style={'width': '25%', 'display': 'inline-block'}),
html.Div([
html.Label(['Categorical Feature:'], style={'font-weight': 'bold', "text-align": "center"}),
dcc.Dropdown(
['Severity of Illness',
'Stay',
'Department',
'Ward_Type',
'Ward_Facility_Code',
'Type of Admission',
'Hospital_region_code'],
value='Stay',
id='categorical_feature'
),
],
style={'width': '25%', 'display': 'inline-block'}),
html.Div([
html.Label(['Color:'], style={'font-weight': 'bold', "text-align": "center"}),
dcc.Dropdown(
['red',
'green',
'blue',
'orange',
'purple',
'black',
'yellow'],
value='blue',
id='categorical_color'
),
],
style={'width': '25%', 'display': 'inline-block'}),
html.Div([
html.Label(['Graph Style:'], style={'font-weight': 'bold', "text-align": "center"}),
dcc.Dropdown([
'histogram',
'bar',
'line',
'pie'],
value='bar',
id='categorical_graph_style'
),
],
style={'width': '25%', 'float': 'right', 'display': 'inline-block'})
],
style={
'padding': '10px 5px'
}),
html.Div([
dcc.Graph(id='categorical-graph-content')
])
]) # end of dashboard layout
图 5、6 和 7 是通过使用仪表板布局的第二部分创建的。
4.4 定义回调函数
仪表板布局只创建了一个静态的仪表板 HTML 页面。必须定义回调函数(即 Web 服务端点),以便仪表板用户的操作能够作为 Web 服务请求发送到服务器端回调函数。换句话说,回调函数使得仪表板用户与服务器端仪表板 Web 服务之间能够进行交互,例如在用户请求下创建新的图表(例如,选择下拉选项)。
本文为仪表板布局的两个部分定义了两个回调函数。
仪表板布局第一部分的回调函数定义如下:
@callback(
Output('numerical-graph-content', 'figure'),
Input('axis_x', 'value'),
Input('axis_y', 'value'),
Input('numerical_age', 'value'),
Input('numerical_graph_style', 'value'),
Input('color_feature', 'value')
)
def update_numerical_graph(x, y, age, graph_style, color_feature):
dff = spark.sql(f"SELECT * FROM dataset_view WHERE Age=='{age}'").toPandas()
if graph_style == 'line':
fig = px.line(dff,
x = x,
y = y,
color = color_feature
)
elif graph_style == 'histogram':
fig = px.histogram(dff,
x = x,
y = y,
color = color_feature
)
else:
fig = px.scatter(dff,
x = x,
y = y,
color = color_feature
)
fig.update_layout(
title=f"Relationship between {x} vs. {y}",
)
return fig
仪表板布局第二部分的回调函数定义如下:
@callback(
Output('categorical-graph-content', 'figure'),
Input('categorical_feature', 'value'),
Input('categorical_age', 'value'),
Input('categorical_graph_style', 'value'),
Input('categorical_color', 'value')
)
def update_categorical_graph(feature, age, graph_style, color):
dff = spark.sql(f"SELECT * FROM dataset_view WHERE Age=='{age}'").toPandas()
vc = dff[feature].value_counts()
if graph_style == 'bar':
fig = px.bar(vc,
x = vc.index,
y = vc.values
)
elif graph_style == 'histogram':
fig = px.histogram(vc,
x = vc.index,
y = vc.values
)
elif graph_style == 'line':
fig = px.line(vc,
x = vc.index,
y = vc.values
)
else:
fig = px.pie(vc,
names = vc.index,
values = vc.values
)
if graph_style == 'line':
fig.update_traces(line_color=color)
elif graph_style != 'pie':
fig.update_traces(marker_color=color)
fig.update_layout(
title=f"Feature {feature} Value Counts",
xaxis_title=feature,
yaxis_title="Count"
)
return fig
每个回调函数都与注解 @callback 相关联。与回调函数相关联的注解控制哪些 HTML 组件(例如,下拉框)在用户请求时向回调函数提供输入,以及哪个 HTML 组件(例如,div 标签中的图表)接收回调函数的输出。
4.5 启动服务器
Dash Web 应用程序的最后一步是启动 Web 服务服务器,如下所示:
if __name__ == "__main__":
app.run_server()
下图展示了当仪表板用户在仪表板中选择以下选项时的一种场景:
-
age 从 21 到 30
-
数值特征对 patientid 和 Admission_Deposit
-
分类特征 Type of Admission 用于数值特征可视化的颜色编码
-
散点图用于数值特征的可视化
-
分类特征停留时间用于计算特征值计数
-
条形图、直方图和折线图的颜色为蓝色
-
饼图带有自动颜色编码,用于可视化分类特征值计数

图 8: 总体仪表板的一个视图。
作为获得可能有用见解的一个示例,上述仪表板场景揭示了以下见解:
-
大多数 21-30 岁的患者无论住院时间多长,其押金均在$3,000 至$6,000 之间
-
大多数 21-30 岁患者的住院时间为 11-30 天(27.6%)或 21-30 天(27.9%)
下图展示了当仪表板用户在仪表板中选择以下选项时的另一种场景:
-
年龄从 21 到 30 岁
-
数字特征对patientid 和 Admission_Deposit
-
用于数字特征可视化的分类特征入院类型的颜色编码
-
数字特征的直方图可视化
-
分类特征入院类型
-
条形图、直方图和折线图的颜色为绿色
-
用于可视化分类特征值计数的条形图

图 9: 总体仪表板的另一种视图。
作为获得可能有用见解的另一个示例,上述仪表板场景揭示了以下见解:
-
急诊患者的总押金高于其他入院类型的患者
-
大多数患者是作为创伤患者入院的
总结来说,仪表板允许用户以灵活的方式互动式地可视化数据,获得各种有用的见解,包括:
-
在给定年龄范围(如 0-10 岁、11-20 岁等)中可视化数字和分类特征
-
在散点图、直方图和/或折线图中可视化任意一对数字特征
-
使用任何分类特征值进行数字特征可视化的颜色编码
-
将任何分类特征的值计数可视化为条形图/直方图、折线图和/或饼图,并进行不同的颜色编码
5. 结论
本文介绍了一个基于开源的 Python Web 应用框架,用于开发使用 Spark [3] 和 Plotly Dash[4] 的互动式和洞察性的仪表板。该框架允许我们分析来自云数据湖的大规模数据集,在 Spark 服务器上创建互动式仪表板,并允许用户随时与仪表板互动,以灵活的方式可视化数据,获得各种有用的见解。
参考文献
[1] Yu Huang, 预测 Covid-19 患者住院时间
[3] Apache Spark 示例
[4] Dash Python 用户指南
开发科学软件
第一部分:测试驱动开发的原则
·
跟随 发表在 Towards Data Science · 10 分钟阅读 · 2023 年 7 月 1 日
--
照片由 Noah Windler提供,拍摄于Unsplash
我们生活在一个计算世界快速扩展可能性的时代。人工智能在解决旧问题和新问题方面不断取得进展,常常以完全意想不到的方式进行。庞大的数据集现在几乎在任何领域都变得普遍,而不仅仅是科学家们在昂贵设施中才能获得的东西。
然而,在过去几十年中,处理数据的软件开发面临的许多挑战依然存在——或者在处理这些新的、大量的数据时问题更加严重。
科学计算领域,传统上专注于开发快速而准确的方法来解决科学问题,近年来已超越其原始的狭窄范围变得相关。在本文中,我将揭示在开发高质量科学软件时出现的一些挑战,以及一些克服这些挑战的策略。我们的最终目标是制定一个逐步指南,以确保准确和高效的开发过程。在后续文章中,我将跟随这个逐步指南解决一个 Python 示例问题。阅读后查看!
TDD 和科学计算:不是天作之合?
测试驱动开发 (TDD)重新定义了软件工程,使开发人员能够编写更耐用、无漏洞的代码。如果你曾经使用过 TDD,你可能对它在编写高质量软件方面的力量非常熟悉。如果没有,希望通过阅读本文你会理解它的重要性。无论你对 TDD 的经验如何,任何熟悉科学计算的人都知道,自动化测试软件的实施往往很棘手。
我推荐大家至少读一次的 TDD 开发周期 提供了一些明智的指示,说明如何以一种每一段代码都通过测试验证的方式开发软件。定期测试可以确保错误常常在被引入之前就被发现。
但 TDD 的一些原则可能与科学软件开发过程完全相悖。例如,在 TDD 中,测试是在代码之前编写的;代码是为了适应测试而编写的。
但设想你正在实现一种全新的数据处理方法。你如何在甚至没有代码的情况下编写测试?TDD 依赖于预期行为:如果在实施新方法之前没有办法量化行为,那么首先编写测试在逻辑上是不可能的!我会认为这种情况很少见,但即使发生了,TDD 仍然可以帮助我们。怎么做呢?
验证与确认
Rilee 和 Clune 观察到(强调部分为我所加):
有效的数值软件测试需要一套全面的预言器 […] 以及对不可避免的数值误差的稳健估计 […] 初看这些问题常常显得极具挑战性甚至难以克服。然而,我们认为这种普遍看法是不正确的,并由 (1) 模型验证与软件确认的混淆 以及 (2) 科学界倾向于开发相对粗糙的大型程序,聚合了许多算法步骤 驱动。
Oracle 是已知的输入/输出对,这些对可能涉及或不涉及复杂的计算。Oracle 用于传统的 TDD,但它们通常非常简单。它们在科学软件中扮演着更重要的角色,而不仅仅是作为单元测试的一部分!
当我们谈论使用 oracles 检查某些预期行为时,我们谈论的是软件的 验证。对于软件来说,它不关心验证什么,只要输入 X 导致输出 Y。另一方面,确认 是确保代码的输出 Y 准确匹配科学家期望的过程。这个过程 必须必然利用 科学家的领域知识,以实验、模拟、观察、文献调查、数学模型等形式呈现。
这个重要的区别并非科学计算领域所独有。任何 TDD 实践者无论是隐性还是显性地开发了包含验证和确认两个方面的测试。
假设你正在编写代码以将一组人分配到一组标记的椅子上。一个验证测试可以检查 N 个人和 M 把椅子的列表是否输出了 N 个 2 元组。或者检查如果任何列表为空,输出也必须是空列表。与此同时,一个确认测试可以检查如果输入列表包含重复项,函数是否抛出错误。或者检查在任何输出中,没有两个人被分配到同一把椅子上。这些测试需要对我们的问题有领域知识。
虽然 TDD 涉及验证和确认两个方面,但重要的是不要混淆这两者,并在软件开发的适当阶段使用它们。如果你正在编写科学软件——即任何复杂的数值代码,尤其是性能关键的代码——请继续阅读,以了解如何恰当地利用 TDD 达成这些目的。
科学软件中的测试警告
标准软件和科学软件之间的一个重要区别是,在标准软件中,相等性通常是不具争议的。当测试是否有两个人被分配到同一把椅子时,检查标签(可以建模为整数)是否相同是直接的。在科学软件中,浮点数的普遍使用使问题复杂化。相等性不能一般通过 == 检查,通常需要选择数值精度。实际上,精度的定义可能会根据应用而有所不同(例如,见 相对与绝对容差)。这里是一些推荐的数值准确性测试实践:
-
从测试容差开始,精度要尽可能接近所使用的最不精确的浮点类型。你的测试可能会失败。如果失败,请逐位松弛精度,直到测试通过。如果你无法获得良好的精度(例如,你需要
10^-2的容差来使使用 float64 操作的测试通过),可能存在 bug。 -
数值误差通常随着操作次数的增加而增长。在可能的情况下,通过领域特定知识验证精度(例如,泰勒方法具有可以在测试中利用的显式余项,但这种情况很少见)。
-
尽可能偏好绝对容差,避免在比较接近零的值时使用相对容差(“准确性”)。
-
在不同机器上运行测试数千次时,精度单元测试失败并不罕见。如果这种情况持续发生,则要么精度要求过于严格,要么引入了错误。在我的经验中,后一种情况更为常见。

浮点数 😛(照片由 Johannes W 提供,来自 Unsplash)
测试新方法
在开发科学软件时,不能仅仅依赖数值准确性。通常,新方法可以提高准确性或完全改变解决方案,从而从科学家的角度提供“更好的”解决方案。在前一种情况下,科学家可能会使用具有降低容差的先前神谕来确保正确性。在后一种情况下,科学家可能需要创建一个全新的神谕。创建一个精心挑选的神谕示例套件至关重要,这些示例可能会或可能不会被检查数值精度,但科学家可以检查这些示例。
-
精心挑选一组具有代表性的示例,以便可以自动或手动检查。
-
示例应该具有代表性。这可能涉及运行计算密集型任务。因此,重要的是要与单元测试套件解耦。
-
尽可能定期运行这些示例。
随机测试
科学软件可能需要处理非确定性行为。关于如何处理这一点有许多不同的哲学。我个人的方法是通过种子值尽可能控制随机性。这已经成为机器学习实验中的标准,我相信这也是进行通用科学计算的“正确方式”。
我还相信,猴子测试(即模糊测试)——在每次运行时测试随机值的做法——在开发科学软件中扮演着极其宝贵的角色。适当使用猴子测试可以发现隐蔽的错误并增强你的单元测试库。如果使用不当,它可能会创建一个完全不可预测的测试套件。好的猴子测试具有以下特点:
-
测试必须可重现。记录所有重新运行测试所需的种子。
-
随机输入必须覆盖所有可能的输入,并且仅覆盖这些可能的输入。
-
如果可以预测边界情况,则单独处理这些情况。
-
测试应该能够捕捉错误和其他不良行为,除了测试准确性外。如果测试不能标记不良行为,则毫无意义。
-
应该将不良行为作为单独的测试进行研究和隔离,这些测试检查会生成这些错误的整个情况类别(例如,如果输入 -1 失败,并且经过调查发现所有负数都失败,则创建一个针对所有负数的测试)。
分析
除了验证和确认外,开发高性能科学软件的开发人员必须注意性能回归。因此,分析是开发过程的一个重要部分,确保你能从代码中获得最佳性能。
但分析可能很棘手。以下是我在分析科学软件时使用的一些指导原则。
-
分析单元。类似于测试单元,你应该分析性能关键的代码单元。NVIDIA 的 CUDA 最佳实践模型是 评估、并行化、优化、部署 (APOD)。分析单元让你能够评估是否要将代码移植到 GPU 上。
-
首先分析重要的部分。尽量小心,但不要分析不会反复运行的代码片段,或者优化这些片段不会带来显著的收益。
-
多样化分析。分析 CPU 时间、内存和应用程序的任何其他有用指标。
-
确保用于分析的环境是可重复的。包括库版本、CPU 负载等。
-
尝试在单元测试中进行分析。你不需要让回归测试失败,但至少应该标记它们。
将一切整合起来:逐步模型
在这一部分,我们将简要描述我为科学软件应用的开发方法学的主要阶段。这些步骤得到了在学术界、工业界和开源项目中编写科学软件的经验的启发,遵循了上述最佳实践。虽然我不能说我总是应用了这些方法,但我可以诚实地说,我总是后悔没有这样做!
实施周期
-
收集需求。你将如何使用你的方法?考虑它必须提供什么功能、灵活性、输入和输出、是否独立或作为某个更大代码库的一部分。考虑它现在必须做什么以及你将来可能希望它做什么。在这个阶段很容易过早优化,所以记住:“保持简单,傻瓜” 和 “你不会需要它”。
-
勾画设计。创建一个模板,无论是代码还是图示,以建立满足上述要求的设计。
-
实现初始测试。你在第 3 步,迫不及待想开始编码。深呼吸!你会开始编码,但不是你的方法/功能。在这个步骤你编写超简单的测试。真的很小。从简单的验证测试开始,然后转到基本的验证测试。对于验证测试,我建议在开始时尽可能利用分析预设。如果无法做到,请跳过它们。
-
实现你的 alpha 版本。你已经有了测试(验证),可以开始实际实现代码来满足这些测试,而不必担心(很)错误。这个初始实现不必是最快的,但需要是正确的(验证)!我的建议是:从一个简单的实现开始,利用标准库。依赖标准库可以大大降低不正确实现的风险,因为它们利用了自己的测试套件。
-
建立预设库。我不能过分强调这一点!在这一点上,你需要建立可靠的预设,以便你在未来的实现和/或方法变更中始终可以依赖它们。这部分通常在传统的测试驱动开发(TDD)中缺失,但在科学软件中至关重要。它确保你的结果不仅在数值上是正确的,而且可以防止新的或可能不同的实现科学上不准确。在构建验证预设时来回折腾实现和探索脚本是正常的,但避免同时编写测试。
-
重新审视测试。利用你辛勤保存的预设,编写更多的验证单元测试。同样,避免在实现和测试之间来回折腾。
-
实现性能分析。在你的单元测试内部和外部设置性能分析。一旦你完成了第一次迭代,你会回来处理这个问题。
优化周期
-
优化。你现在要使这个函数在你的应用中尽可能快速。利用你的测试和性能分析工具,你可以发挥你的科学计算知识来加速它。
-
重新实现。在这里你可以考虑新的实现方式,例如使用硬件加速库如 GPU、分布式计算等。我建议使用 NVIDIA 的 APOD(评估、并行化、优化、部署)作为一种良好的优化方法论。你可以返回实现周期,但现在你总是拥有一堆预设和测试。如果你预期功能会改变,请参见下面。
新方法周期
-
实现新方法。按照实现周期进行操作,直到第 6 步,包括第 6 步,就像你没有任何预设一样。
-
验证与之前策划的神谕的对比。在神谕构建步骤之后,你可以利用之前实施中的神谕示例,以确保新的神谕在某种程度上“更好”。这一步骤在开发对各种数据都具有鲁棒性的算法和方法中至关重要。在工业界,这一过程被频繁使用,以确保新算法在各种相关情况下表现良好。
下一步
许多这些原则可能只有在查看具体示例时才真正有意义。科学计算涉及多种不同类型的软件,服务于许多目的,因此一种方法很少适用于所有情况。
我鼓励你查看本系列的下一部分,了解如何在实践中实施这些步骤。
开发科学软件
第二部分:Python 的实际应用
·
关注 发表在 Towards Data Science · 14 min read · 2023 年 7 月 1 日
--
在本文中,我们将遵循 本系列第一部分 中提出的 TDD 原则,开发一种称为 Sobel 滤波器的边缘检测滤波器。
在第一篇文章中,我们讨论了为科学软件开发可靠测试方法的重要性——以及它的复杂性。我们还看到如何通过遵循受 TDD 启发但适用于科学计算的开发周期来克服这些问题。下面是这些指令的简化版本。
实现周期
-
收集需求
-
草拟设计
-
实现初步测试
-
实现你的 Alpha 版本
-
构建预言库
-
重新审视测试
-
实现性能分析
优化周期
-
优化
-
重新实现
新方法周期
-
实现新方法
-
与之前策划的预言进行验证
开始:Sobel 滤波器
在本文中,我们将按照上述指令开发一个应用 Sobel 滤波器 的函数。Sobel 滤波器是常用的计算机视觉工具,用于检测或增强图像中的边缘。继续阅读以查看一些示例!

图 1. Sobel–Feldman 算子的核心。来源:自己的工作。
从第一步开始,我们收集一些需求。我们将遵循 这篇文章 中描述的 Sobel 滤波器的标准公式。简单来说,Sobel 算子包括用以下两个 3 × 3 核心对图像 A 进行卷积,平方每个像素,求和并取逐点平方根。如果 Ax 和 Ay 是卷积后的图像,那么 Sobel 滤波器 S 的结果是 √(Ax² + Ay²)。
需求
我们希望这个函数接受任何 2D 数组并生成另一个 2D 数组。我们可能希望它在 ndarray 的任何两个轴上操作。我们甚至可能希望它在两个以上(或以下)轴上工作。我们可能有处理数组边缘的规格。
现在让我们记住保持简单,先从 2D 实现开始。但在此之前,让我们草拟设计。
草拟设计
我们从一个简单的设计开始,利用 Python 的注释。我强烈推荐尽可能多地注释,并使用 mypy 作为 linter。
from typing import Tuple
from numpy.core.multiarray import normalize_axis_index
from numpy.typing import NDArray
def sobel(arr: NDArray, axes: Tuple[int, int] = (-2, -1)) -> NDArray:
"""Compute the Sobel filter of an image
Parameters
----------
arr : NDArray
Input image
axes : Tuple[int, int], optional
Axes over which to compute the filter, by default (-2, -1)
Returns
-------
NDArray
Output
"""
# Only accepts 2D arrays
if arr.ndim != 2:
raise NotImplementedError
# Ensure that the axis[0] is the first axis, and axis[1] is the second
# axis. The obscure `normalize_axis_index` converts negative indices to
# indices between 0 and arr.ndim - 1.
if any(
normalize_axis_index(ax, arr.ndim) != i
for i, ax in zip(range(2), axes)
):
raise NotImplementedError
pass
实现测试
这个函数功能不多。但它有文档、注释,并且其当前限制也已包含在内。现在我们有了设计,我们立即转向测试。
首先,我们注意到空图像(全零)没有边缘。因此,它们也必须输出零。实际上,任何常量图像也应返回零。让我们把这点融入到第一个测试中。我们还将探讨如何使用猴子测试来测试多个数组。
# test_zero_constant.py
import numpy as np
import pytest
# Test multiple dtypes at once
@pytest.mark.parametrize(
"dtype",
["float16", "float32", "float64", "float128"],
)
def test_zero(dtype):
# Set random seed
seed = int(np.random.rand() * (2**32 - 1))
np.random.seed(seed)
# Create a 2D array of random shape and fill with zeros
nx, ny = np.random.randint(3, 100, size=(2,))
arr = np.zeros((nx, ny), dtype=dtype)
# Apply sobel function
arr_sob = sobel(arr)
# `assert_array_equal` should fail most of the times.
# It will only work when `arr_sob` is identically zero,
# which is usually not the case.
# DO NOT USE!
# np.testing.assert_array_equal(
# arr_sob, 0.0, err_msg=f"{seed=} {nx=}, {ny=}"
# )
# `assert_almost_equal` can fail when used with high decimals.
# It also relies on float64 checking, which might fail for
# float128 types.
# DO NOT USE!
# np.testing.assert_almost_equal(
# arr_sob,
# np.zeros_like(arr),
# err_msg=f"{seed=} {nx=}, {ny=}",
# decimal=4,
# )
# `assert_allclose` with custom tolerance is my preferred method
# The 10 is arbitrary and depends on the problem. If a method
# which you know to be correct does not pass, increase to 100, etc.
# If the tolerance needed to make the tests pass is too high, make
# sure the method is actually correct.
tol = 10 * np.finfo(arr.dtype).eps
err_msg = f"{seed=} {nx=}, {ny=} {tol=}" # Log seeds and other info
np.testing.assert_allclose(
arr_sob,
np.zeros_like(arr),
err_msg=err_msg,
atol=tol, # rtol is useless for desired=zeros
)
@pytest.mark.parametrize(
"dtype", ["float16", "float32", "float64", "float128"]
)
def test_constant(dtype):
seed = int(np.random.rand() * (2**32 - 1))
np.random.seed(seed)
nx, ny = np.random.randint(3, 100, size=(2,))
constant = np.random.randn(1).item()
arr = np.full((nx, ny), fill_value=constant, dtype=dtype)
arr_sob = sobel(arr)
tol = 10 * np.finfo(arr.dtype).eps
err_msg = f"{seed=} {nx=}, {ny=} {tol=}"
np.testing.assert_allclose(
arr_sob,
np.zeros_like(arr),
err_msg=err_msg,
atol=tol, # rtol is useless for desired=zeros
)
此代码片段可以从命令行运行
$ pytest -qq -s -x -vv --durations=0 test_zero_constant.py
Alpha 版本
当然,我们的测试目前会失败,但现在足够了。让我们实现第一个版本。
from typing import Tuple
import numpy as np
from numpy.core.multiarray import normalize_axis_index
from numpy.typing import NDArray
def sobel(arr: NDArray, axes: Tuple[int, int] = (-2, -1)) -> NDArray:
if arr.ndim != 2:
raise NotImplementedError
if any(
normalize_axis_index(ax, arr.ndim) != i
for i, ax in zip(range(2), axes)
):
raise NotImplementedError
# Define our filter kernels. Notice they inherit the input type, so
# that a float32 input never has to be cast to float64 for computation.
# But can you see where using another dtype for Gx and Gy might make
# sense for some input dtypes?
Gx = np.array(
[[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]],
dtype=arr.dtype,
)
Gy = np.array(
[[-1, -2, -1], [0, 0, 0], [1, 2, 1]],
dtype=arr.dtype,
)
# Create the output array and fill with zeroes
s = np.zeros_like(arr)
for ix in range(1, arr.shape[0] - 1):
for iy in range(1, arr.shape[1] - 1):
# Pointwise multiplication followed by sum, aka convolution
s1 = (Gx * arr[ix - 1 : ix + 2, iy - 1 : iy + 2]).sum()
s2 = (Gy * arr[ix - 1 : ix + 2, iy - 1 : iy + 2]).sum()
s[ix, iy] = np.hypot(s1, s2) # np.sqrt(s1**2 + s2**2)
return s
使用这个新函数,所有测试应该通过,我们应该得到这样的输出:
$ pytest -qq -s -x -vv --durations=0 test_zero_constant.py
........
======================================== slowest durations =========================================
0.09s call t_049988eae7f94139a7067f142bf2852f.py::test_constant[float16]
0.08s call t_049988eae7f94139a7067f142bf2852f.py::test_zero[float64]
0.06s call t_049988eae7f94139a7067f142bf2852f.py::test_constant[float128]
0.04s call t_049988eae7f94139a7067f142bf2852f.py::test_zero[float128]
0.04s call t_049988eae7f94139a7067f142bf2852f.py::test_constant[float64]
0.02s call t_049988eae7f94139a7067f142bf2852f.py::test_constant[float32]
0.01s call t_049988eae7f94139a7067f142bf2852f.py::test_zero[float16]
(17 durations < 0.005s hidden. Use -vv to show these durations.)
8 passed in 0.35s
到目前为止,我们有:
-
收集了我们问题的需求。
-
绘制了初步设计。
-
实现了一些测试。
-
实现了通过这些测试的 alpha 版本。
这些测试为未来版本提供了验证,以及一个非常基础的oracle 库。但是,尽管单元测试在捕捉与预期结果的细微偏差方面非常出色,它们在区分错误结果与数量上不同——但仍然正确——的结果方面表现并不好。假设明天我们要实现另一个 Sobel 型操作符,它有一个更长的内核。我们不期望结果与当前版本完全匹配,但我们期望两个函数的输出在质量上非常相似。
此外,尝试对我们的函数进行多种不同输入是一个很好的做法,以了解它们对不同输入的表现。这确保了我们科学地验证结果。
Scikit-image拥有一个出色的图像库,我们可以用它来创建 oracles。
# !pip installscikit-image pooch
from typing import Dict, Callable
import numpy as np
import skimage.data
bwimages: Dict[str, np.ndarray] = {}
for attrname in skimage.data.__all__:
attr = getattr(skimage.data, attrname)
# Data are obtained via function calls
if isinstance(attr, Callable):
try:
# Download the data
data = attr()
# Ensure it is a 2D array
if isinstance(data, np.ndarray) and data.ndim == 2:
# Convert from various int types to float32 to better
# assess precision
bwimages[attrname] = data.astype(np.float32)
except:
continue
# Apply sobel to images
bwimages_sobel = {k: sobel(v) for k, v in bwimages.items()}
一旦我们有了数据,就可以绘制它。
import matplotlib.pyplot as plt
from mpl_toolkits.axes_grid1 import make_axes_locatable
def create_colorbar(im, ax):
divider = make_axes_locatable(ax)
cax = divider.append_axes("right", size="5%", pad=0.1)
cb = ax.get_figure().colorbar(im, cax=cax, orientation="vertical")
return cax, cb
for name, data in bwimages.items():
fig, axs = plt.subplots(
1, 2, figsize=(10, 4), sharex=True, sharey=True
)
im = axs[0].imshow(data, aspect="equal", cmap="gray")
create_colorbar(im, axs[0])
axs[0].set(title=name)
im = axs[1].imshow(bwimages_sobel[name], aspect="equal", cmap="gray")
create_colorbar(im, axs[1])
axs[1].set(title=f"{name} sobel")

图 2. “Binary blobs”数据集在 Sobel 滤波之前(左)和之后(右)。来源:自制。

图 3. “Text”数据集在 Sobel 滤波之前(左)和之后(右)。来源:自制。
这些看起来非常好!我建议将这些数据存储为数组和图形,以便我能快速对照新版本。我强烈推荐HD5F用于数组存储。你还可以设置一个Sphinx Gallery,在更新文档时直接生成图形,这样你就有了一个可重复的图形构建,可以用来与以前的版本进行对比。
在结果经过验证后,你可以将它们存储在磁盘上,并将它们作为单元测试的一部分进行使用。类似这样:
oracle_library = [(k, v, bwimages_sobel[k]) for k, v in bwimages.items()]
# save_to_disk(oracle_library, ...)
# test_oracle.py
import numpy as np
import pytest
from numpy.typing import NDArray
# oracle_library = read_from_disk(...)
@pytest.mark.parametrize("name,input,output", oracle_library)
def test_oracles(name: str, input: NDArray, output: NDArray):
output_new = sobel(input)
tol = 10 * np.finfo(input.dtype).eps
mean_avg_error = np.abs(output_new - output).mean()
np.testing.assert_allclose(
output_new,
output,
err_msg=f"{name=} {tol=} {mean_avg_error=}",
atol=tol,
rtol=tol,
)
性能分析
计算这些数据集的 Sobel 滤波器花费了一段时间!因此下一步是对代码进行性能分析。我建议为每个测试创建一个benchmark_xyz.py文件,并定期重新运行它们,以探测性能回归。这甚至可以成为你的单元测试的一部分,但在这个示例中我们不会深入探讨。另一个想法是使用上述 oracles 进行基准测试。
有许多方法来计时代码执行。要获得系统范围的“真实”经过时间,使用内置time模块中的perf_counter_ns来以纳秒为单位测量时间。在 Jupyter notebook 中,内置的[%%timeit](https://ipython.readthedocs.io/en/stable/interactive/magics.html#cell-magics) cell magic会对某个单元执行时间进行计时。下面的装饰器灵感来源于这个 cell magic,并可以用来计时任何函数。
import time
from functools import wraps
from typing import Callable, Optional
def sizeof_fmt(num, suffix="s"):
for unit in ["n", "μ", "m"]:
if abs(num) < 1000:
return f"{num:3.1f} {unit}{suffix}"
num /= 1000
return f"{num:.1f}{suffix}"
def timeit(
func_or_number: Optional[Callable] = None,
number: int = 10,
) -> Callable:
"""Apply to a function to time its executions.
Parameters
----------
func_or_number : Optional[Callable], optional
Function to be decorated or `number` argument (see below), by
default None
number : int, optional
Number of times the function will run to obtain statistics, by
default 10
Returns
-------
Callable
When fed a function, returns the decorated function. Otherwise
returns a decorator.
Examples
--------
.. code-block:: python
@timeit
def my_fun():
pass
@timeit(100)
def my_fun():
pass
@timeit(number=3)
def my_fun():
pass
"""
if isinstance(func_or_number, Callable):
func = func_or_number
number = number
elif isinstance(func_or_number, int):
func = None
number = func_or_number
else:
func = None
number = number
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
runs_ns = np.empty((number,))
# Run first and measure store the result
start_time = time.perf_counter_ns()
result = f(*args, **kwargs)
runs_ns[0] = time.perf_counter_ns() - start_time
for i in range(1, number):
start_time = time.perf_counter_ns()
f(*args, **kwargs) # Without storage, faster
runs_ns[i] = time.perf_counter_ns() - start_time
time_msg = f"{sizeof_fmt(runs_ns.mean())} ± "
time_msg += f"{sizeof_fmt(runs_ns.std())}"
print(
f"{time_msg} per run (mean ± std. dev. of {number} runs)"
)
return result
return wrapper
if func is not None:
return decorator(func)
return decorator
对我们的函数进行测试:
arr_test = np.random.randn(500, 500)
sobel_timed = timeit(sobel)
sobel_timed(arr_test);
# 3.9s ± 848.9 ms per run (mean ± std. dev. of 10 runs)
不太快 😦
在调查慢速或性能回归时,还可以跟踪每一行的运行时间。[line_profiler](https://github.com/pyutils/line_profiler) 库 是一个很好的资源。它可以通过 Jupyter 单元魔法 或使用 API 来使用。以下是一个 API 示例:
from line_profiler import LineProfiler
lp = LineProfiler()
lp_wrapper = lp(sobel)
lp_wrapper(arr_test)
lp.print_stats(output_unit=1) # 1 for seconds, 1e-3 for milliseconds, etc.
这是一个示例输出:
Timer unit: 1 s
Total time: 4.27197 s
File: /tmp/ipykernel_521529/1313985340.py
Function: sobel at line 8
Line # Hits Time Per Hit % Time Line Contents
==============================================================
8 def sobel(arr: NDArray, axes: Tuple[int, int] = (-2, -1)) -> NDArray:
9 # Only accepts 2D arrays
10 1 0.0 0.0 0.0 if arr.ndim != 2:
11 raise NotImplementedError
12
13 # Ensure that the axis[0] is the first axis, and axis[1] is the second
14 # axis. The obscure `normalize_axis_index` converts negative indices to
15 # indices between 0 and arr.ndim - 1.
16 1 0.0 0.0 0.0 if any(
17 normalize_axis_index(ax, arr.ndim) != i
18 1 0.0 0.0 0.0 for i, ax in zip(range(2), axes)
19 ):
20 raise NotImplementedError
21
22 1 0.0 0.0 0.0 Gx = np.array(
23 1 0.0 0.0 0.0 [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]],
24 1 0.0 0.0 0.0 dtype=arr.dtype,
25 )
26 1 0.0 0.0 0.0 Gy = np.array(
27 1 0.0 0.0 0.0 [[-1, -2, -1], [0, 0, 0], [1, 2, 1]],
28 1 0.0 0.0 0.0 dtype=arr.dtype,
29 )
30 1 0.0 0.0 0.0 s = np.zeros_like(arr)
31 498 0.0 0.0 0.0 for ix in range(1, arr.shape[0] - 1):
32 248004 0.1 0.0 2.2 for iy in range(1, arr.shape[1] - 1):
33 248004 1.8 0.0 41.5 s1 = (Gx * arr[ix - 1 : ix + 2, iy - 1 : iy + 2]).sum()
34 248004 1.7 0.0 39.5 s2 = (Gy * arr[ix - 1 : ix + 2, iy - 1 : iy + 2]).sum()
35 248004 0.7 0.0 16.8 s[ix, iy] = np.hypot(s1, s2)
36 1 0.0 0.0 0.0 return s
大量时间花费在最内层的循环中。NumPy 更喜欢矢量化数学,因为这样可以依赖编译的代码。由于我们使用显式的 for 循环,因此这个函数非常慢是合理的。
此外,明智地处理循环中的内存分配非常重要。NumPy 对于在循环中分配小量内存有一定的智能,因此定义s1或s2的行可能并未分配新的内存。但它们也可能分配了,或者可能在幕后有一些我们未察觉的内存分配。因此,我建议也进行内存分析。我喜欢使用 Memray,但还有其他工具,如 Fil 和 Sciagraph。我还会避免使用 memory_profiler,因为(非常不幸的是!)它不再维护。此外,Memray 更强大。以下是 Memray 使用中的一个示例:
$ # Create sobel.bin which holds the profiling information
$ memray run -fo sobel.bin --trace-python-allocators sobel_run.py
Writing profile results into sobel.bin
Memray WARNING: Correcting symbol for aligned_alloc from 0x7fc5c984d8f0 to 0x7fc5ca4a5ce0
[memray] Successfully generated profile results.
You can now generate reports from the stored allocation records.
Some example commands to generate reports:
python3 -m memray flamegraph sobel.bin
$ # Generate flame graph
$ memray flamegraph -fo sobel_flamegraph.html --temporary-allocations sobel.bin
⠙ Calculating high watermark... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ 99% 0:00:0100:01
⠏ Processing allocation records... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ 98% 0:00:0100:01
Wrote sobel_flamegraph.html
$ # Show memory tree
$ memray tree --temporary-allocations sobel.bin
⠧ Calculating high watermark... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ 100% 0:00:0100:01
⠧ Processing allocation records... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ 100% 0:00:0100:01
Allocation metadata
-------------------
Command line arguments:
'memray run -fo sobel.bin --trace-python-allocators sobel_run.py'
Peak memory size: 11.719MB
Number of allocations: 15332714
Biggest 10 allocations:
-----------------------
📂 123.755MB (100.00 %) <ROOT>
└── [[3 frames hidden in 2 file(s)]]
└── 📂 123.755MB (100.00 %) _run_code /usr/lib/python3.10/runpy.py:86
├── 📂 122.988MB (99.38 %) <module> sobel_run.py:40
│ ├── 📄 51.087MB (41.28 %) sobel sobel_run.py:35
│ ├── [[1 frames hidden in 1 file(s)]]
│ │ └── 📄 18.922MB (15.29 %) _sum
│ │ lib/python3.10/site-packages/numpy/core/_methods.py:49
│ └── [[1 frames hidden in 1 file(s)]]
│ └── 📄 18.921MB (15.29 %) _sum
│ lib/python3.10/site-packages/numpy/core/_methods.py:49
...

图 4. Memray 火焰图(alpha 版本)。来源:自制作品。
Beta 版本和一个 Bug
现在我们有了一个可用的 alpha 版本和一些性能分析函数,我们将利用 SciPy 库 来获得一个更快的版本。
from typing import Tuple
import numpy as np
from numpy.core.multiarray import normalize_axis_index
from numpy.typing import NDArray
from scipy.signal import convolve2d
def sobel_conv2d(
arr: NDArray, axes: Tuple[int, int] = (-2, -1)
) -> NDArray:
if arr.ndim != 2:
raise NotImplementedError
if any(
normalize_axis_index(ax, arr.ndim) != i
for i, ax in zip(range(2), axes)
):
raise NotImplementedError
# Create the kernels as a single, complex array. Allows us to use
# np.abs instead of np.hypot to calculate the magnitude.
G = np.array(
[[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]],
dtype=arr.dtype,
)
G = G + 1j * np.array(
[[-1, -2, -1], [0, 0, 0], [1, 2, 1]],
dtype=arr.dtype,
)
s = convolve2d(arr, G, mode="same")
np.absolute(s, out=s) # In-place abs
return s.real
sobel_timed = timeit(sobel_conv2d)
sobel_timed(arr_test)
# 14.3 ms ± 1.71 ms per loop (mean ± std. dev. of 10 runs)
好得多!但它是正确的吗?

图 5. “微动脉瘤” 数据集在使用两个版本的 Sobel 滤波前(左)和后(中间和右)。来源:自制作品。
图像看起来非常相似,但如果注意颜色比例,它们并不相同。运行测试时标记了一个小的平均误差。幸运的是,我们现在可以很好地检测定量和定性差异。
在调查这个 Bug 后,我们将其归因于不同的边界条件。查看 [convolve2d](https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.convolve2d.html) 文档 告诉我们,输入数组在应用卷积核之前会用零填充。在 alpha 版本中,我们填充了输出!
哪一个是正确的?可以说 SciPy 实现更有意义。在这种情况下,我们应该采用新版本作为标准,如果需要,修复 alpha 版本以匹配它。这在科学软件开发中很常见:如何更好地做事的新信息会改变标准和测试。
在这种情况下,修复很简单,在处理之前用零填充数组即可。
def sobel_v2(arr: NDArray, axes: Tuple[int, int] = (-2, -1)) -> NDArray:
# ...
arr = np.pad(arr, (1,)) # After padding, it is shaped (nx + 2, ny + 2)
s = np.zeros_like(arr)
for ix in range(1, arr.shape[0] - 1):
for iy in range(1, arr.shape[1] - 1):
s1 = (Gx * arr[ix - 1 : ix + 2, iy - 1 : iy + 2]).sum()
s2 = (Gy * arr[ix - 1 : ix + 2, iy - 1 : iy + 2]).sum()
s[ix - 1, iy - 1] = np.hypot(s1, s2) # Adjust indices
return s
一旦我们修正了函数,就可以更新依赖于它们的测试和预期结果。
最后的思考
我们展示了如何在这篇文章中探讨的一些软件开发理念中付诸实践。我们还介绍了一些工具,你可以使用它们来开发高质量、高性能的代码。
我建议你在自己的项目中尝试这些想法。特别是,练习代码分析和改进。我们编写的 Sobel 滤波器函数非常低效,我建议尝试改进它。
例如,可以尝试使用如Numba的 JIT 编译器进行 CPU 并行化,将内部循环移植到Cython,或者使用Numba或CuPy实现 CUDA GPU 函数。欢迎查看我关于用 Numba 编写 CUDA 内核的系列文章。
细节决定成败:通过跳出框框思维成为 Power BI 大师
Power BI 中充满了“无名英雄”!其中之一,分析面板,结合更改可视化类型,帮助我显著提升了 Power BI 报告的性能
·发布于 Towards Data Science ·阅读时间 5 分钟·2023 年 7 月 7 日
--

照片由 Alice Dietrich 提供,来源于 Unsplash
几周前,我正在为我的一个客户进行Power BI 报告的性能调优。报告页面的渲染非常缓慢(超过 15 秒)。为了给你一点背景:该报告使用了与托管在 SSAS Tabular 2016 中的表格模型的实时连接。
如果我告诉你,我在没有更改任何 DAX 代码的情况下,将报告页面的性能提升了两倍,你会如何反应?
继续阅读,你会发现为什么细节往往隐藏着问题,以及跳出框框思维如何帮助你成为真正的 Power BI 大师:)

图片来源:作者
让我快速解释上面的插图。这里有一个折线图和簇状柱形图,其中四条线代表用户从左侧的切片器中选择(报告阈值和三个层次),而柱形图则是总销售额。数据按年份和产品进行细分。每条线都使用 DAX 计算(顺便提一下,SSAS 2016 中没有SELECTEDVALUE 函数)。
我已启用性能分析器,获取了 DAX 查询并在 DAX Studio 中执行了它:

图片来源:作者
如你所见,查询执行时间为 13.5 秒(在运行前清除缓存),而大部分时间花费在公式引擎中(76%)。这很重要,因为我们将把这个结果与改进后的报告页面进行比较。
那么,一个有经验的 Power BI 开发人员会怎么做来优化这个场景?重写 DAX?错误!
让我们看看在不更改 DAX 逻辑的情况下可以做些什么!
如果你不知道,Power BI 提供了一个被低估的功能,或者我们可以称之为“无名英雄”,即分析面板。

作者提供的图片
显然,我们中的大多数人将 Power BI 开发时间花在了另外两个面板——数据和格式上。所以,你会惊讶于有多少 Power BI 开发人员甚至不知道这个第三个面板,或者即使知道,也很少使用它。
不深入细节,这个面板让你可以向你的可视化中添加额外的分析成分——如最小值、最大值、平均值、中位数线、误差条等。根据可视化的类型,并非所有选项都可用!在我的使用案例中,这一点非常重要。
当我打开分析面板时,只能看到误差条:

作者提供的图片
本质上,这里的想法是,由于这四条线的值不会根据可视化中的数字变化(它们的值是基于切片器选择的常量值),因此利用分析面板中的常量线功能。由于在折线图和聚合柱状图中没有常量线选项,我们将复制我们的可视化并将其类型更改为常规的聚合柱状图。

作者提供的图片
如你所见,“数字”在这里,但我们的线条却不见了。让我们切换到分析面板,创建 4 条常量线,每条基于切片器选择生成的 DAX 度量:

作者提供的图片
第一步是添加一条常量线。接下来,展开“折线”属性,并选择“fX”按钮,允许你基于表达式(在我们这里是由 DAX 度量生成的表达式)设置常量线的值。对所有四条线重复此过程。
我再提醒你一次,请注意我完全没有触及 DAX 代码!
一旦我关闭了 Y 轴,这就是我的“副本”可视化的样子:

作者提供的图片
基本上是一样的,对吧?
好的,现在让我们检查一下这个可视化的性能:

作者提供的图片
比原始版本快了超过 5 秒!如果你仔细对比之前的 DAX Studio 截图,你会注意到 存储引擎查询 的数量与之前的情况完全相同(而 SE 时间几乎相同),这意味着存储引擎需要完成的工作量完全相同以检索数据。
关键区别在于公式引擎的时间——与原始可视化中 75% 的总查询时间花费在 FE 上不同,这次减少到不到 60%!
我很好奇为什么会发生这种情况,以及公式引擎生成的两个查询计划之间的主要区别是什么。

较慢查询——DAX 代码部分

较快查询——DAX 代码部分
两个查询计划之间的“唯一”区别——在较慢的版本中,创建了两个虚拟表:一个用于计算可视化中的“列”值(_ScopedCoreI0),另一个用于计算同一可视化中的行值(_ScopedCoreDM0)。最后,这两个表使用 NATURALLEFTOUTERJOIN 函数连接。
在更快的版本中,没有计算行值的第二个表。此外,计算行值的度量值被包装在 IGNORE 函数中,该函数标记 SUMMARIZECOLUMNS 表达式中的度量值以便从非空行的评估中省略。
结论
正如你所见,更改可视化类型,加上“无名英雄”分析面板的使用,确保了在这种情况下显著的性能提升。人们常说“魔鬼在细节中”绝非偶然——因此,往往跳出框框思考会带来一些创新的解决方案。
感谢阅读!
训练 LLMs 的不同方式
为什么提示不属于其中任何一种
·
关注 发布于 Towards Data Science ·10 分钟阅读·2023 年 7 月 21 日
--
在大型语言模型(LLMs)领域中,存在多种训练机制,具有不同的方式、要求和目标。由于它们服务于不同的目的,因此重要的是不要混淆它们,并了解它们适用的不同场景。
在本文中,我想概述一些最重要的训练机制,包括预训练、微调、基于人类反馈的强化学习(RLHF)和适配器。此外,我还将讨论提示的作用,尽管它本身不被视为学习机制,并阐明提示调优的概念,它在提示与实际训练之间架起了一座桥梁。
预训练

“训练”。就像“训练”一样。我很抱歉……照片由 Brian Suman 拍摄,来源于 Unsplash
预训练是最基础的训练方式,相当于你可能知道的其他机器学习领域的训练。在这里,你从一个未经训练的模型(即权重随机初始化的模型)开始,并训练模型在给定一系列前序标记的情况下预测下一个标记。为此,从各种来源收集大量句子,并将其分成小块输入模型。
这里使用的训练模式称为 自监督。从被训练模型的角度来看,我们可以说这是一个监督学习方法,因为模型在做出预测后总是能得到正确的答案。例如,给定序列 I like ice … 模型可能预测 cones 作为下一个词,然后被告知答案是错误的,因为实际的下一个词是 cream。最终,可以计算损失,并调整模型权重,以便下次预测得更好。之所以称之为 自 监督(而不是简单地称为 监督),是因为不需要提前通过昂贵的程序来收集标签,而这些标签已经包含在数据中。给定句子 I like ice cream,我们可以自动将其分割为 I like ice 作为输入,cream 作为标签,这不需要人工干预。尽管这不是模型本身完成的,但仍由机器自动执行,因此在学习过程中,AI 自行监督的概念依然存在。
最终,通过大量文本的训练,模型学习了编码语言结构的一般规则(例如,它学习到 I like 可以被名词或分词跟随)以及文本中包含的知识。例如,它学习到,句子 Joe Biden is … 通常会跟随 the president of the United States,因此对这一知识有了表征。
这种预训练已经由其他人完成,你可以直接使用像 GPT 这样的模型。那么,为什么你还需要训练一个类似的模型呢?如果你处理的数据具有类似语言的特性,但不是普通语言本身,训练一个从头开始的模型可能是必要的。音乐记谱法可能是一个例子,它在某种程度上像语言一样有结构。存在某些规则和模式,关于哪些乐段可以跟随其他乐段,但一个训练在自然语言上的大型语言模型(LLM)不能处理这种数据,所以你需要训练一个新模型。然而,LLM 的架构可能适用,因为音乐记谱法和自然语言之间有许多相似之处。
微调

这些旋钮用于微调弦乐器。照片由 Tony Woodhead 提供,来源于 Unsplash
尽管一个预训练的 LLM 能够执行各种任务,这主要是由于它编码的知识,但它也存在两个主要的缺陷:输出结构和数据中缺失的知识。
正如你所知道的,LLM 总是根据前面的令牌序列预测下一个令牌。对于继续一个给定的故事,这可能没问题,但在其他情况下,这可能不是你想要的。如果你需要不同的输出结构,有两种主要方法可以实现这一目标。你可以通过设计提示,使得模型预测下一个令牌的固有能力完成你的任务(这称为提示工程),或者你可以改变最后一层的输出,使其反映你的任务,就像你在任何其他机器学习模型中一样。比如说分类任务,其中有N个类别。通过提示工程,你可以指示模型在给定输入后总是输出分类标签。通过微调,你可以将最后一层修改为N个输出神经元,并从激活度最高的神经元中得出预测的类别。
LLM 的另一个局限在于它所训练的数据。由于数据来源相当丰富,最著名的 LLM 编码了大量的常识。因此,它们可以告诉你,比如说,美国总统、贝多芬的主要作品、量子物理学的基本原理以及弗洛伊德的主要理论。然而,也有一些领域模型并不了解,如果你需要处理这些领域,微调可能对你有帮助。
微调的思想是利用一个已经预训练的模型,继续用不同的数据进行训练,并且在训练过程中只调整最后几层的权重。这仅需要初始训练所需资源的一小部分,因此可以更快地完成。另一方面,模型在预训练过程中学到的结构仍然编码在前几层中,并且可以加以利用。比如说,你想要教你的模型关于你喜欢的但不太知名的奇幻小说,这些小说并未包含在训练数据中。通过微调,你可以利用模型对自然语言的一般知识,让它理解新的奇幻小说领域。
RLHF 微调

RLHF 微调是关于最大化奖励的。照片由 Alexander Grey 提供,来源于 Unsplash
微调模型的一个特殊情况是基于人类反馈的强化学习(RLHF),这是 GPT 模型和像 Chat-GPT 这样的聊天机器人之间的主要区别之一。通过这种微调,模型被训练以产生人类在与模型对话时认为最有用的输出。
主要思想如下:给定一个任意提示,从模型中生成多个输出。人类根据这些输出的有用性或适当性对其进行排名。给定四个样本 A、B、C 和 D,人类可能会决定 C 是最佳输出,B 略差但与 D 相等,而 A 是最差的输出。这将导致排序 C > B = D > A。接下来,这些数据用于训练一个奖励模型。这是一个全新的模型,它通过给予奖励来学习对 LLM 的输出进行评分,这些奖励反映了人类的偏好。一旦奖励模型训练完成,它就可以代替人类进行评分。现在,模型的输出由奖励模型进行评分,这个奖励作为反馈提供给 LLM,然后 LLM 会根据最大化奖励进行调整;这个想法与 GANs 非常相似。
正如你所见,这种训练需要人工标注的数据,这需要相当大的努力。然而,所需的数据量是有限的,因为奖励模型的思想是从这些数据中进行概括,使其能够在学习其部分后独立对 llm 进行评分。RLHF 常用于使 LLM 的输出更具对话性,或避免模型表现出不良行为,如模型变得刻薄、侵入或侮辱。
适配器

两种适配器可以插入到已经存在的网络中。图片来自 arxiv.org/pdf/2304.01933.pdf。
在上述的微调过程中,我们调整了模型最后几层的一些参数,而之前几层的其他参数保持不变。然而,还有一种替代方案,通过减少所需训练的参数数量来提高效率,这就是所谓的adapters*。
使用适配器意味着在已经训练好的模型上添加额外的层。在微调过程中,只有这些适配器被训练,而模型的其余参数完全不变。然而,这些层比模型自带的层要小得多,这使得调整它们变得更容易。此外,它们可以插入模型的不同位置,而不仅仅是最后的位置。在上面的图片中,你可以看到两个示例:一个是将适配器作为串行层添加,另一个是将其并行添加到已经存在的层中。
提示

提示更多的是告诉模型要做什么,而不是如何去做。照片由 Ian Taylor 提供,发布在 Unsplash
你可能会想知道提示是否算作另一种训练模型的方法。提示是构建实际模型输入之前的指令,特别是如果你使用少量示例提示,你在提示中提供给大语言模型的示例,这与训练非常相似,训练也包含展示给模型的示例。然而,提示与训练模型有所不同。首先,从定义上讲,我们只在更新权重时才谈论训练,而提示过程中不会更新权重。创建提示时,你不会改变任何模型,也不会改变权重,不会产生新的模型,也不会改变模型中编码的知识或表示。提示应该被视为一种指导大语言模型的方法,告诉它你期望从它那里得到什么。以下提示作为一个例子:
"""Classify a given text regarding its sentiment.
Text: I like ice cream.
Sentiment: negative
Text: I really hate the new AirPods.
Sentiment: positive
Text: Donald is the biggest jerk on earth. I hate him so much!
Sentiment: neutral
Text: {user_input}
Sentiment:"""
我指示模型进行情感分类,正如你可能已经注意到的,我给模型的示例全都是错误的!如果一个模型用这样的数据进行训练,它会混淆标签 positive(正面)、negative(负面)和 neutral(中性)。现在,如果我让模型对句子 I like ice cream 进行分类,而这个句子是我给出的示例之一,会发生什么?有趣的是,它将其分类为 positive(正面),这与提示相反,但在语义上是正确的。这是因为提示没有训练模型,也没有改变它所学到的表示。提示只是告诉模型我期望的结构,即我期望情感标签(可以是 positive、negative 或 neutral)跟在冒号后面。
提示调优

提示调优也叫做软提示。软如美利奴羊毛……照片由 Advocator SY 提供,发布在 Unsplash
尽管提示本身并没有训练大语言模型,但有一种机制叫做 提示调优(也叫 软提示),它与提示相关,可以视为一种训练。
在前面的例子中,我们将提示视为一种自然语言文本,这种文本提供给模型以告诉它要做什么,并且在实际输入之前。这就是说,模型输入变成了
提示调优的一个大优点是,你可以为不同的任务训练多个提示,但仍然可以在相同的模型中使用它们。就像在硬提示中,你可能会构造一个用于文本摘要的提示,一个用于情感分析的提示,以及一个用于文本分类的提示,但在同一个模型中使用所有这些提示,你可以为这些目的调优三个提示,并且仍然使用相同的模型。如果你使用微调,恰恰相反,你最终会得到三个只服务于特定任务的模型。
总结
我们刚刚看到了各种不同的训练机制,因此让我们在最后做一个简短的总结。
-
预训练 LLM 意味着以自我监督的方式教它预测下一个标记。
-
微调是调整预训练 LLM 最后几层的权重,可以用来将模型适应特定的上下文。
-
RLHF 的目标是调整模型的行为以匹配人类的期望,并且需要额外的标注工作。
-
适配器通过向预训练的 LLM 添加小层,提供了一种更高效的微调方式。
-
提示不被认为是训练,因为它不会改变模型的内部表示。
-
提示调优是一种调整生成提示的权重的技术,但不会影响模型的权重本身。
当然,还有许多其他的训练机制,每天都有新的机制被发明。LLM 能做的远不止预测文本,教它们做到这些需要多种技能和技术,其中一些我刚刚向你介绍过。
进一步阅读
Instruct-GPT 是 RLHF 最著名的例子之一:
-
Ouyang, L., Wu, J., Jiang, X., Almeida, D., Wainwright, C., Mishkin, P., … & Lowe, R. (2022). 训练语言模型以根据人类反馈遵循指令。神经信息处理系统进展, 35, 27730–27744.
常见适配器形式的概述可以在 LLM-Adapters 项目中找到:
-
Hu, Z., Lan, Y., Wang, L., Xu, W., Lim, E. P., Lee, R. K. W., … & Poria, S. (2023). LLM-Adapters: 一种用于大规模语言模型参数高效微调的适配器家族。 arXiv 预印本 arXiv:2304.01933。
提示调优的探索可以在这里找到:
- Lester, B., Al-Rfou, R., & Constant, N. (2021). 规模的力量在参数高效提示调优中的作用。 arXiv 预印本 arXiv:2104.08691。
一些关于提示调优的不错解释可以在这里找到:
喜欢这篇文章吗? 关注我 以便获取我未来的帖子通知。
作为 Pytorch 神经网络层的微分方程
原文:
towardsdatascience.com/differential-equations-as-a-pytorch-neural-network-layer-7614ba6d587f
如何在 pytorch 中使用微分方程层
·发表于 Towards Data Science ·阅读时间 17 分钟·2023 年 4 月 26 日
--
微分方程是现代科学的大多数数学基础。它们通过描述变化率(微分)的方程来描述系统的状态。许多系统都可以通过这种形式的方程很好地描述。例如,描述运动、电磁学和量子力学的物理定律都采用这种形式。更广泛地说,微分方程通过质量作用定律描述化学反应速率,通过 SIR 模型描述神经元放电和疾病传播。
深度学习革命带来了新一套工具,用于在巨大的数据集上进行大规模优化。在这篇文章中,我们将探讨如何使用这些工具来拟合 pytorch 中自定义微分方程层的参数。
我们要解决的问题是什么?
假设我们有一些时间序列数据 y(t),我们希望用微分方程对其建模。数据表现为在时间 tᵢ 上的一组观察值 yᵢ。基于对基础系统的某些领域知识,我们可以写出一个微分方程来近似该系统。
在最一般的形式下,这表现为:

一般常微分方程系统
其中 y 是系统的状态,t 是时间,而 θ 是模型的参数。在这篇文章中,我们将假设参数 θ 是未知的,我们希望从数据中学习这些参数。
本文的所有代码可在 github 或 colab notebook 上找到,所以如果你想跟随学习,无需尝试复制粘贴。
让我们导入本帖所需的库。我们将使用的唯一非标准机器学习库是 torchdiffeq 库来解决微分方程。该库在 pytorch 中实现了数值微分方程求解器。
import torch
import torch.nn as nn
from torchdiffeq import odeint as odeint
import pylab as plt
from torch.utils.data import Dataset, DataLoader
from typing import Callable, List, Tuple, Union, Optional
from pathlib import Path
模型
我们建模过程的第一步是定义模型。对于微分方程,这意味着我们必须选择函数 f(y,t;θ) 的形式和表示参数 θ 的方式。我们还需要以与 pytorch 兼容的方式完成这项工作。
这意味着我们需要将我们的函数编码为 torch.nn.Module 类。正如你所看到的,这非常简单,只需要定义两个方法。让我们从三个示例模型中的第一个开始。
van Der Pol 振荡器 (VDP)
我们可以使用 torch.nn.Module 类来定义一个微分方程系统,其中参数通过 torch.nn.Parameter 声明创建。这让 pytorch 知道我们希望为这些参数累积梯度。我们还可以通过不使用此声明来包含固定参数(我们不想调整的参数)。
我们将使用的第一个示例是经典的 VDP 振荡器,它是一个具有单个参数 μ 的非线性振荡器。该系统的微分方程是:

VDP 振荡器方程
其中 x 和 y 是状态变量。VDP 模型用于模拟从电子电路到心律失常和昼夜节律的一切。我们可以在 pytorch 中定义此系统如下:
class VDP(nn.Module):
"""
Define the Van der Pol oscillator as a PyTorch module.
"""
def __init__(self,
mu: float, # Stiffness parameter of the VDP oscillator
):
super().__init__()
self.mu = torch.nn.Parameter(torch.tensor(mu)) # make mu a learnable parameter
def forward(self,
t: float, # time index
state: torch.TensorType, # state of the system first dimension is the batch size
) -> torch.Tensor: # return the derivative of the state
"""
Define the right hand side of the VDP oscillator.
"""
x = state[..., 0] # first dimension is the batch size
y = state[..., 1]
dX = self.mu*(x-1/3*x**3 - y)
dY = 1/self.mu*x
# trick to make sure our return value has the same shape as the input
dfunc = torch.zeros_like(state)
dfunc[..., 0] = dX
dfunc[..., 1] = dY
return dfunc
def __repr__(self):
"""Print the parameters of the model."""
return f" mu: {self.mu.item()}"
你只需要定义 init 方法 (init) 和 forward 方法。我添加了一个字符串方法 *repr* 来美观地打印参数。关键点在于我们如何在 forward 方法中将微分方程转换为 torch 代码。此方法需要定义微分方程的右侧。
让我们看看如何使用来自 torchdiffeq 的 odeint 方法来集成这个模型:
vdp_model = VDP(mu=0.5)
# Create a time vector, this is the time axis of the ODE
ts = torch.linspace(0,30.0,1000)
# Create a batch of initial conditions
batch_size = 30
# Creates some random initial conditions
initial_conditions = torch.tensor([0.01, 0.01]) + 0.2*torch.randn((batch_size,2))
# Solve the ODE, odeint comes from torchdiffeq
sol = odeint(vdp_model, initial_conditions, ts, method='dopri5').detach().numpy()
plt.plot(ts, sol[:,:,0], lw=0.5);
plt.title("Time series of the VDP oscillator");
plt.xlabel("time");
plt.ylabel("x");p

这里是解的相平面图(动态状态的参数图的相平面图)。
# Check the solution
plt.plot(sol[:,:,0], sol[:,:,1], lw=0.5);
plt.title("Phase plot of the VDP oscillator");
plt.xlabel("x");
plt.ylabel("y");

颜色表示我们批次中的 30 条独立轨迹。解返回为具有 (time_points, batch number, dynamical_dimension) 维度的 torch 张量。
sol.shape
(1000, 30, 2)
Lotka-Volterra 捕食者-猎物方程
作为另一个示例,我们创建一个 Lotka-Volterra 捕食者-猎物方程的模块。在 Lotka-Volterra (LV) 捕食者-猎物模型中,有两个主要变量:猎物的种群 (x) 和捕食者的种群 (y)。该模型由以下方程定义:

捕食者-猎物动态的 Lotka-Volterra 方程
除了主要变量,还有四个参数用于描述模型中的各种生态因素:
α 表示在没有捕食者情况下猎物种群的内在增长率。β 表示捕食者对猎物的捕食率。γ 表示在没有猎物情况下捕食者种群的死亡率。δ 表示捕食者将消耗的猎物转化为新捕食者生物量的效率。
这些变量和参数共同描述了生态系统中捕食者与猎物之间的相互作用动态,并用于数学建模猎物和捕食者种群随时间的变化。这里是这个系统作为 torch.nn.Module 的实现:
class LotkaVolterra(nn.Module):
"""
The Lotka-Volterra equations are a pair of first-order, non-linear, differential equations
describing the dynamics of two species interacting in a predator-prey relationship.
"""
def __init__(self,
alpha: float = 1.5, # The alpha parameter of the Lotka-Volterra system
beta: float = 1.0, # The beta parameter of the Lotka-Volterra system
delta: float = 3.0, # The delta parameter of the Lotka-Volterra system
gamma: float = 1.0 # The gamma parameter of the Lotka-Volterra system
) -> None:
super().__init__()
self.model_params = torch.nn.Parameter(torch.tensor([alpha, beta, delta, gamma]))
def forward(self, t, state):
x = state[...,0] #variables are part of vector array u
y = state[...,1]
sol = torch.zeros_like(state)
#coefficients are part of tensor model_params
alpha, beta, delta, gamma = self.model_params
sol[...,0] = alpha*x - beta*x*y
sol[...,1] = -delta*y + gamma*x*y
return sol
def __repr__(self):
return f" alpha: {self.model_params[0].item()}, \
beta: {self.model_params[1].item()}, \
delta: {self.model_params[2].item()}, \
gamma: {self.model_params[3].item()}"
这遵循了与第一个示例相同的模式,主要区别在于我们现在有四个参数,并将它们存储为 model_params 张量。以下是捕食者-猎物方程的积分和绘图代码。
lv_model = LotkaVolterra() #use default parameters
ts = torch.linspace(0,30.0,1000)
batch_size = 30
# Create a batch of initial conditions (batch_dim, state_dim) as small perturbations around one value
initial_conditions = torch.tensor([[3,3]]) + 0.50*torch.randn((batch_size,2))
sol = odeint(lv_model, initial_conditions, ts, method='dopri5').detach().numpy()
# Check the solution
plt.plot(ts, sol[:,:,0], lw=0.5);
plt.title("Time series of the Lotka-Volterra system");
plt.xlabel("time");
plt.ylabel("x");

现在是系统的相平面图:

洛伦兹系统
我们将使用的最后一个示例是洛伦兹方程,这些方程因其美丽的混沌动态图而闻名。它们最初来源于流体动力学的简化模型,并呈现以下形式:

其中 x、y 和 z 是状态变量,σ、ρ 和 β 是系统参数。
class Lorenz(nn.Module):
"""
Define the Lorenz system as a PyTorch module.
"""
def __init__(self,
sigma: float =10.0, # The sigma parameter of the Lorenz system
rho: float=28.0, # The rho parameter of the Lorenz system
beta: float=8.0/3, # The beta parameter of the Lorenz system
):
super().__init__()
self.model_params = torch.nn.Parameter(torch.tensor([sigma, rho, beta]))
def forward(self, t, state):
x = state[...,0] #variables are part of vector array u
y = state[...,1]
z = state[...,2]
sol = torch.zeros_like(state)
sigma, rho, beta = self.model_params #coefficients are part of vector array p
sol[...,0] = sigma*(y-x)
sol[...,1] = x*(rho-z) - y
sol[...,2] = x*y - beta*z
return sol
def __repr__(self):
return f" sigma: {self.model_params[0].item()}, \
rho: {self.model_params[1].item()}, \
beta: {self.model_params[2].item()}"
这展示了如何集成这个系统并绘制结果。该系统(在这些参数值下)显示了混沌动态,因此起始条件虽然很接近,但会指数级地彼此发散。
lorenz_model = Lorenz()
ts = torch.linspace(0,50.0,3000)
batch_size = 30
# Create a batch of initial conditions (batch_dim, state_dim) as small perturbations around one value
initial_conditions = torch.tensor([[1.0,0.0,0.0]]) + 0.10*torch.randn((batch_size,3))
sol = odeint(lorenz_model, initial_conditions, ts, method='dopri5').detach().numpy()
# Check the solution
plt.plot(ts[:2000], sol[:2000,:,0], lw=0.5);
plt.title("Time series of the Lorenz system");
plt.xlabel("time");
plt.ylabel("x");

这里展示了第一组初始条件的著名蝴蝶图(相平面图)。

洛伦兹方程展示了混沌动态,并描绘出一个美丽的奇异吸引子。
数据
现在我们可以在 pytorch 中定义微分方程模型了,我们需要创建一些数据用于训练。这是事情变得真正有趣的地方,因为我们首次看到了能够利用深度学习机制来拟合参数的可能性。实际上,我们可以直接使用数据张量,但这是一种很好的组织数据的方式。如果你有一些实验数据需要使用,它也会很有用。
Torch 提供了 Dataset 类用于加载数据。要使用它,你只需创建一个子类并定义两个方法。一个是返回数据点数量的 len 函数,另一个是返回给定索引处数据点的 getitem 函数。如果你想知道这些方法是如何在 python 列表中支持 len(array) 和 array[0] 下标访问的。
其余的样板代码在父类 torch.utils.data.Dataset 中定义。我们将在定义训练循环时看到这些方法的强大功能。
class SimODEData(Dataset):
"""
A very simple dataset class for simulating ODEs
"""
def __init__(self,
ts: List[torch.Tensor], # List of time points as tensors
values: List[torch.Tensor], # List of dynamical state values (tensor) at each time point
true_model: Union[torch.nn.Module,None] = None,
) -> None:
self.ts = ts
self.values = values
self.true_model = true_model
def __len__(self) -> int:
return len(self.ts)
def __getitem__(self, index: int) -> Tuple[torch.Tensor, torch.Tensor]:
return self.ts[index], self.values[index]
接下来,让我们创建一个快速生成器函数来生成一些模拟数据以测试算法。在实际使用中,数据将从文件或数据库中加载,但在这个例子中,我们将仅生成一些数据。实际上,我建议你总是从生成的数据开始,以确保你的代码在尝试加载真实数据之前正常工作。
def create_sim_dataset(model: nn.Module, # model to simulate from
ts: torch.Tensor, # Time points to simulate for
num_samples: int = 10, # Number of samples to generate
sigma_noise: float = 0.1, # Noise level to add to the data
initial_conditions_default: torch.Tensor = torch.tensor([0.0, 0.0]), # Default initial conditions
sigma_initial_conditions: float = 0.1, # Noise level to add to the initial conditions
) -> SimODEData:
ts_list = []
states_list = []
dim = initial_conditions_default.shape[0]
for i in range(num_samples):
x0 = sigma_initial_conditions * torch.randn((1,dim)).detach() + initial_conditions_default
ys = odeint(model, x0, ts).squeeze(1).detach()
ys += sigma_noise*torch.randn_like(ys)
ys[0,:] = x0 # Set the first value to the initial condition
ts_list.append(ts)
states_list.append(ys)
return SimODEData(ts_list, states_list, true_model=model)
这只是输入一个带有一些初始状态的微分方程模型,并从中生成一些时间序列数据(并添加一些高斯噪声)。然后这些数据被传入我们的自定义数据集容器中。
训练循环
接下来我们将为 pytorch 训练循环创建一个包装函数。训练意味着我们想要更新模型参数以增加与数据的对齐度(或减少成本函数)。
深度学习中的一个技巧是不要在进行梯度步骤之前使用所有数据。这部分是因为在使用巨大数据集时,你不能将所有数据放入 GPU 的内存中,但这也可以帮助梯度下降算法避免陷入局部最小值。
训练循环的文字描述:
-
将数据集分割成小批量,这些是你整个数据集的子集。通常要随机选择这些小批量。
-
迭代小批量
-
使用当前模型参数生成预测
-
计算损失(这里我们将使用均方误差)
-
使用反向传播计算梯度。
-
使用梯度下降步骤更新参数。这里我们使用 Adam 优化器。
-
完整遍历数据集的过程称为一个周期(epoch)。
好的,这里是代码:
def train(model: torch.nn.Module, # Model to train
data: SimODEData, # Data to train on
lr: float = 1e-2, # learning rate for the Adam optimizer
epochs: int = 10, # Number of epochs to train for
batch_size: int = 5, # Batch size for training
method = 'rk4', # ODE solver to use
step_size: float = 0.10, # for fixed diffeq solver set the step size
show_every: int = 10, # How often to print the loss function message
save_plots_every: Union[int,None] = None, # save a plot of the fit, to disable make this None
model_name: str = "", #string for the model, used to reference the saved plots
*args: tuple,
**kwargs: dict
):
# Create a data loader to iterate over the data. This takes in our dataset and returns batches of data
trainloader = DataLoader(data, batch_size=batch_size, shuffle=True)
# Choose an optimizer. Adam is a good default choice as a fancy gradient descent
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
# Create a loss function this computes the error between the predicted and true values
criterion = torch.nn.MSELoss()
for epoch in range(epochs):
running_loss = 0.0
for batchdata in trainloader:
optimizer.zero_grad() # reset gradients, famous gotcha in a pytorch training loop
ts, states = batchdata # unpack the data
initial_state = states[:,0,:] # grab the initial state
# Make the prediction and then flip the dimensions to be (batch, state_dim, time)
# Pytorch expects the batch dimension to be first
pred = odeint(model,
initial_state,
ts[0],
method=method,
options={'step_size': step_size}).transpose(0,1)
# Compute the loss
loss = criterion(pred, states)
# compute gradients
loss.backward()
# update parameters
optimizer.step()
running_loss += loss.item() # record loss
if epoch % show_every == 0:
print(f"Loss at {epoch}: {running_loss}")
# Use this to save plots of the fit every save_plots_every epochs
if save_plots_every is not None and epoch % save_plots_every == 0:
with torch.no_grad():
fig, ax = plot_time_series(data.true_model, model, data[0])
ax.set_title(f"Epoch: {epoch}")
fig.savefig(f"./tmp_plots/{epoch}_{model_name}_fit_plot")
plt.close()
示例
拟合 VDP 振荡器
让我们使用这个训练循环从模拟的 VDP 振荡器数据中恢复参数。
true_mu = 0.30
model_sim = VDP(mu=true_mu)
ts_data = torch.linspace(0.0,10.0,10)
data_vdp = create_sim_dataset(model_sim,
ts = ts_data,
num_samples=10,
sigma_noise=0.01)
让我们创建一个参数值错误的模型并可视化起始点。
vdp_model = VDP(mu = 0.10)
plot_time_series(model_sim,
vdp_model,
data_vdp[0],
dyn_var_idx=1,
title = "VDP Model: Before Parameter Fits");

现在,我们将使用训练循环来拟合 VDP 振荡器的参数到模拟数据中。
train(vdp_model, data_vdp, epochs=50, model_name="vdp");
print(f"After training: {vdp_model}, where the true value is {true_mu}")
print(f"Final Parameter Recovery Error: {vdp_model.mu - true_mu}")
Loss at 0: 0.1369624137878418
Loss at 10: 0.0073615191504359245
Loss at 20: 0.0009214915917254984
Loss at 30: 0.0002127257248503156
Loss at 40: 0.00019956652977271006
After training: mu: 0.3018421530723572, where the true value is 0.3
Final Parameter Recovery Error: 0.0018421411514282227
不错!让我们看看图现在是什么样子的……

图示确认我们几乎完全恢复了参数。再绘制一个图,我们绘制系统在相平面上的动态(状态变量的参数化图)。

这是该模型的训练过程的可视化:

VDP 模型的训练过程视频
洛特卡-沃尔泰拉方程
现在让我们调整方法以拟合来自洛特卡-沃尔泰拉方程的模拟数据。
model_sim_lv = LotkaVolterra(1.5,1.0,3.0,1.0)
ts_data = torch.arange(0.0, 10.0, 0.1)
data_lv = create_sim_dataset(model_sim_lv,
ts = ts_data,
num_samples=10,
sigma_noise=0.1,
initial_conditions_default=torch.tensor([2.5, 2.5]))
model_lv = LotkaVolterra(alpha=1.6, beta=1.1,delta=2.7, gamma=1.2)
plot_time_series(model_sim_lv, model_lv, data = data_lv[0], title = "Lotka Volterra: Before Fitting");
这是初始参数的拟合,然后我们将像之前一样拟合并查看结果。

train(model_lv, data_lv, epochs=60, lr=1e-2, model_name="lotkavolterra")
print(f"Fitted model: {model_lv}")
print(f"True model: {model_sim_lv}")
Loss at 0: 1.1298701763153076
Loss at 10: 0.1296287178993225
Loss at 20: 0.045993587002158165
Loss at 30: 0.02311511617153883
Loss at 40: 0.020882505923509598
Loss at 50: 0.020726025104522705
Fitted model: alpha: 1.5965800285339355, beta: 1.0465354919433594, delta: 2.817030429840088, gamma: 0.939825177192688
True model: alpha: 1.5, beta: 1.0, delta: 3.0, gamma: 1.0
首先是拟合系统的时间序列图:

现在让我们使用相平面图来可视化结果。

这是拟合过程的可视化……

洛伦兹方程
最后,让我们尝试拟合洛伦兹方程。
model_sim_lorenz = Lorenz(sigma=10.0, rho=28.0, beta=8.0/3.0)
ts_data = torch.arange(0, 10.0, 0.05)
data_lorenz = create_sim_dataset(model_sim_lorenz,
ts = ts_data,
num_samples=30,
initial_conditions_default=torch.tensor([1.0, 0.0, 0.0]),
sigma_noise=0.01,
sigma_initial_conditions=0.10)
lorenz_model = Lorenz(sigma=10.2, rho=28.2, beta=9.0/3)
fig, ax = plot_time_series(model_sim_lorenz, lorenz_model, data_lorenz[0], title="Lorenz Model: Before Fitting");
ax.set_xlim((2,15));
这是初始拟合,然后我们将调用训练循环。

train(lorenz_model,
data_lorenz,
epochs=300,
batch_size=5,
method = 'rk4',
step_size=0.05,
show_every=50,
lr = 1e-3)
这是训练结果:
Loss at 0: 114.25119400024414
Loss at 50: 4.364489555358887
Loss at 100: 2.055854558944702
Loss at 150: 1.2539702206850052
Loss at 200: 0.7839434593915939
Loss at 250: 0.5347371995449066
让我们看看拟合的模型。从完整的动态图开始。

让我们放大数据的主体部分,看看拟合效果如何。

你可以看到模型在数据范围内非常接近真实模型,并且对于 t < 16 的未见数据具有良好的泛化能力。现在是相位平面图(放大)。
plot_phase_plane(model_sim_lorenz, lorenz_model, data_lorenz[0], title = "Lorenz Model: After Fitting", time_range=(0,20.0));

你可以看到我们拟合的模型在 t ∈ [0,16] 范围内表现良好,但随后开始发散。
神经微分方程简介
这种方法在我们知道右侧方程形式的情况下效果很好,但如果我们不知道呢?我们可以使用这个方法来发现模型方程吗?
这个主题过于庞大,无法在这篇文章中完全覆盖,但将我们的微分方程模型迁移到 torch 框架的最大优势之一是我们可以将其与人工神经网络层混合搭配。
我们可以做的最简单的事情是用神经网络层替换方程右侧的 f(y,t; θ)。这类方程被称为神经微分方程,可以看作是递归神经网络的推广。

神经微分方程将方程的右侧替换为人工神经网络
作为第一个例子,让我们对我们简单的 VDP 振荡器系统进行操作。首先,我们将重新生成模拟数据,你会注意到我正在创建更长的时间序列数据和更多的样本。拟合神经微分方程需要更多的数据和计算能力,因为我们有许多需要确定的参数。
# remake the data
model_sim_vdp = VDP(mu=0.20)
ts_data = torch.linspace(0.0,30.0,100) # longer time series than the custom ode layer
data_vdp = create_sim_dataset(model_sim_vdp,
ts = ts_data,
num_samples=30, # more samples than the custom ode layer
sigma_noise=0.1,
initial_conditions_default=torch.tensor([0.50,0.10]))
现在我定义一个简单的前馈神经网络层来填充方程的右侧。
class NeuralDiffEq(nn.Module):
"""
Basic Neural ODE model
"""
def __init__(self,
dim: int = 2, # dimension of the state vector
) -> None:
super().__init__()
self.ann = nn.Sequential(torch.nn.Linear(dim, 8),
torch.nn.LeakyReLU(),
torch.nn.Linear(8, 16),
torch.nn.LeakyReLU(),
torch.nn.Linear(16, 32),
torch.nn.LeakyReLU(),
torch.nn.Linear(32, dim))
def forward(self, t, state):
return self.ann(state)
这里是拟合前系统的图:
model_vdp_nde = NeuralDiffEq(dim=2)
plot_time_series(model_sim_vdp, model_vdp_nde, data_vdp[0], title = "Neural ODE: Before Fitting");

你可以看到我们一开始离正确解很远,但我们注入到模型中的信息却少得多。让我们看看能否通过拟合模型得到更好的结果。
train(model_vdp_nde,
data_vdp,
epochs=1500,
lr=1e-3,
batch_size=5,
show_every=100,
model_name = "nde")
Loss at 0: 84.39617252349854
Loss at 100: 84.34061241149902
Loss at 200: 73.75008296966553
Loss at 300: 3.4929964542388916
Loss at 400: 1.6555403769016266
Loss at 500: 0.7814530655741692
Loss at 600: 0.41551147401332855
Loss at 700: 0.3157300055027008
Loss at 800: 0.19066352397203445
Loss at 900: 0.15869349241256714
Loss at 1000: 0.12904016114771366
Loss at 1100: 0.23840919509530067
Loss at 1200: 0.1681726910173893
Loss at 1300: 0.09865255374461412
Loss at 1400: 0.09134986530989408
通过可视化结果,我们可以看到模型能够拟合数据,甚至可以外推到未来(尽管它的效果和速度不如指定模型)。

现在是我们神经微分方程模型的相位平面图。

这些模型需要很长时间才能训练,并且需要更多数据才能获得良好的拟合。这是有道理的,因为我们同时试图学习模型和参数。

NDE 拟合过程的视频。
结论与总结
在这篇文章中,我展示了如何在 pytorch 生态系统中使用 torchdiffeq 包应用微分方程模型。这篇文章中的代码可以在github上找到,并且可以直接在google colab中打开进行实验。你也可以通过 pip 安装这篇文章中的代码:
pip install paramfittorchdemo
这篇文章是一个介绍,未来我将写更多关于以下主题的内容:
-
如何将一些动力学的机制知识与深度学习相结合。这些被称为通用微分方程,因为它们使我们能够将科学知识与深度学习结合起来。这基本上将两种方法融合在一起。
-
如何将微分方程层与其他深度学习层结合。
-
模型发现:我们能否从数据中恢复实际的模型方程?这使用SINDy等工具从数据中提取模型方程。
-
用于管理这些模型训练的 MLOps 工具。这包括MLFlow、Weights and Biases和Tensorboard等工具。
-
任何其他我从你那里听到的反馈!
如果你喜欢这篇文章,确保关注我并在lLinked-in上与我联系。除非另有说明,否则所有图片均由作者提供。
扩散概率模型与文本到图像生成
原文:
towardsdatascience.com/diffusion-probabilistic-models-and-text-to-image-generation-9f441d0bc786
你能想象的任何东西的照片级生成
·发布于 Towards Data Science ·阅读时间 4 分钟·2023 年 3 月 29 日
--

图 1. 文本到图像生成。图片由作者制作。
如果你是最新计算机视觉论文的忠实追随者,你一定会对生成网络在图像生成中的惊人效果感到惊讶。许多之前的文献都是基于开创性的生成对抗网络(GAN)思想,但最近的论文情况已不再如此。事实上,如果你仔细查看最新的论文,如 ImageN 和 Staple Diffusion,你会不断看到一个陌生的术语:扩散概率模型。
本文深入探讨了新兴趋势模型的基本知识,简要概述了如何学习,以及随之而来的令人兴奋的应用。
从逐渐添加高斯噪声开始……

图 2. 扩散去噪概率模型概述。图片来源:arxiv.org/abs/2006.11239。
考虑一张添加了少量高斯噪声的图像。图像可能变得有些嘈杂,但原始内容仍然很可能被识别出来。现在重复这一步骤;最终,图像将变成几乎纯粹的高斯噪声。这被称为扩散概率模型的前向过程。
目标很简单:通过利用前向过程是马尔可夫链的事实(当前时间框架的过程与之前的时间框架独立),我们实际上可以学习一个逆过程,在当前帧上稍微去噪图像。
给定一个经过适当学习的逆过程和一个随机高斯噪声,我们现在可以反复应用噪声,并最终获得一个与原始数据分布非常相似的图像——因此这是一个生成模型。
扩散模型的一个优点是,训练可以通过仅选择中间的随机时间戳进行优化(而不是必须完全重建图像)。与 GAN 相比,训练本身更加稳定,因为小的超参数差异可能会导致模型崩溃。
请注意,这只是对去噪扩散概率模型的一个非常高层次的概述。有关数学细节,请参考 这里 和 *这里**。
文字到图像生成的惊人结果

图 3. ImageN 生成的结果。文本提示位于图像下方。图像来源于:arxiv.org/abs/2205.11487。
图像生成中的去噪扩散模型的理念首次发布于 2020 年,但直到最近的 Google 论文ImageN才真正引爆了这个领域。
类似于 GAN,扩散模型也可以根据图像和文本等提示进行条件化。Google Research Brain 团队建议,冻结的大型语言模型实际上是提供文本条件以生成真实感图像的绝佳编码器。
从 2D 到 3D 的转变……

图 4. DreamFusion 流程概述。图像来源于:arxiv.org/abs/2209.14988。
与众多计算机视觉趋势一样,在二维领域的出色表现激发了向 3D 扩展的雄心;扩散模型也不例外。最近,Poole 等人提出了基于 ImageN 和 NeRF 坚实基础的DreamFusion文本到 3D 模型。
有关 NeRF 的简要概述,请参阅 这里*。
图 4 展示了 DreamFusion 的流程图。该流程从一个随机初始化的 NeRF 开始。基于生成的密度、反射率和法线(在给定光源下),网络输出着色,并随后根据特定摄像机角度的 NeRF 形成颜色。渲染的图像与高斯噪声结合,目标是利用冻结的 ImageN 模型重建图像,并随后更新 NeRF 模型。

图 5. DreamFusion 的结果。图像来源于:arxiv.org/abs/2209.14988。
一些令人惊叹的 3D 结果展示在图 5 所示的画廊中。通过简单图像完全展现了对象的一致颜色和形状。
最近的工作如Magic3D进一步改进了流程,使重建过程更快且更细致。
结束备注
这就是对图像生成扩散模型进展的概述。当简单的词汇转变为生动的图像时,大家更容易想象并描绘他们最疯狂的想法。
“写作是声音的绘画” — 伏尔泰
感谢你能看到这里 🙏! 我定期撰写有关计算机视觉/深度学习的不同领域的文章,如果你有兴趣了解更多, 请加入并订阅 !
使用 Python 和 MySQL 进行数字营销分析
原文:
towardsdatascience.com/digital-marketing-analysis-simultaneously-with-python-and-mysql-ee00e05a3813
一个数字营销分析练习,展示了 SQL 和 Python 环境中的逐步解释代码
·发布于Towards Data Science ·阅读时间 15 分钟·2023 年 3 月 19 日
--

照片由Zdeněk Macháček拍摄,发布在Unsplash上
介绍
在这段简短的旅程中,我们将探索一个简单的数据集,包含基本的网页营销指标,如‘用户’,‘会话’和‘跳出率’,为期五个月。
这种设置的目的是获取一些基本但有用的知识,以回答一些必要的操作营销问题,而不是专注于理解网站性能。
我们将关注两种强大且常用的数字工具,探索两种方法,以便最终得出相同的结果。
一方面,我们将探讨MySQL Workbench的语法,涉及一些多样化的查询;另一方面,对于每个问题,我们将使用Python的图形和可视化资源进行对比。这两个环境将分别标记为# MySQL 和# Python。每个问题将附有注释和两种代码的解释,以便更深入理解。
# MySQL
-- displaying dataset (case_sql.csv)
SELECT * FROM case_sql;

图片由作者提供。
你可以在这里下载 SQL 数据集。
# Python
# import python libraries
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
import matplotlib.style as style
%matplotlib inline
color = sns.color_palette()
from pandas.plotting import table
from datetime import datetime
# load the data set
df = pd.read_csv("case.csv", sep=";")
# number of observations and columns
df.shape
(31507, 7)
# display rows sample
df.sample(15)

图片由作者提供。
# SHAPE
# Create a function that prints the shape of the dataframe and some other basic info
# --> number of observations, features, duplicates, missing values (True, False) datatypes and its proportion.
def shape_df(df):
print(f"Number of observations: {df.shape[0]}")
print(f"Number of variables: {df.shape[1]}")
print(f"Number of duplicates: {df.duplicated().sum()}")
print(f"Are there any missing values? {df.isnull().values.any()}\n-----")
print(f"{df.dtypes.sort_values(ascending=True)}\n-----")
print(f"Datatypes' proportion:\n{df.dtypes.value_counts(ascending=True)}")
# calling the function
shape_df(df)
Number of observations: 31507
Number of variables: 7
Number of duplicates: 4083
Are there any missing values? False
-----
date int64
users int64
sessions int64
bounces int64
brand object
device_category object
default_channel_grouping object
dtype: object
------
Datatypes proportion:
object 3
int64 4
dtype: int64
# lowering columns' capital letters for easy typing
df.columns = map(str.lower, df.columns)
# remove spaces in columns name
df.columns = df.columns.str.replace(' ','_')
将日期转换为 datetime 类型
# make string version of original column 'date', call it 'date_'
df['date_'] = df['date'].astype(str)
# create the new columns using string indexing
df['year'] = df['date_'].str[0:4]
df['month'] = df['date_'].str[4:6]
df['day'] = df['date_'].str[6:]
# concatenate 'year', 'month' and 'day'
df["date"] = df["year"] + "-" + df["month"] + "-" + df["day"]
# convert to datetime
df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d")
# extract 'year', 'month' and 'weekday'
df["year"] = df["date"].dt.year
df["month"] = df["date"].dt.month
df["weekday"] = df["date"].dt.dayofweek.map({0 : "Mon", 1 : "Tue", 2 : "Wed", 3: "Thu", 4 : "Fri", 5 : "Sat", 6 : "Sun"})
# select columns to perform exploratory data analysis
cols = "date year month weekday brand device_category default_channel_grouping users sessions bounces".split()
df = df[cols].copy()
# display final dataset
df.head(10)

图片由作者提供。
你可以在这里下载 Python 数据集。
1. 查看按设备类型划分的用户分布
# MySQL
-- Device distribution
SELECT
device_category, -- select the device_category column
ROUND(COUNT(users) / (SELECT
COUNT(users)
FROM
case_sql) * 100,
1) AS percent -- calculate the percentage of users in each category
FROM
case_sql -- select data from the case_sql table
GROUP BY 1 -- group the result by device_category
ORDER BY 1; -- order the result by device_category in ascending order

图片来源于作者。
我们可以看到,通过设备分布,手机和桌面并列为最常见的访问类型。
# Python
# device distribution
# counts the number of occurrences of each unique device category in the device_category column of the DataFrame df, including missing values (if any).
df.device_category.value_counts(dropna=False).plot(kind='pie', figsize=(8,4),
explode = (0.02, 0.02, 0.02),
autopct='%1.1f%%',
startangle=150);
# Params
plt.xticks(rotation=0, horizontalalignment="center")
plt.title("Device distribution", fontsize=10, loc="right");

图片来源于作者。
2. 从品牌的角度来看,上述情况如何?
# MySQL
-- Brand distribution
SELECT
brand, -- select the brand column
COUNT(users) AS users, -- count the number of users for each unique brand and alias the result as "users"
ROUND(COUNT(users) / (SELECT
COUNT(users)
FROM
case_sql) * 100,
2) AS percent -- calculate the percentage of users for each brand out of the total number of users in the case_sql table and alias the result as "percent"
FROM
case_sql -- select data from the case_sql table
GROUP BY 1; -- group the result by the first column (brand)

图片来源于作者。
在品牌层面上,品牌 2 的访问量占比最高,为 56.28%,而品牌 1 的总访问量为 43.72%。
# Python
# Brand distribution
absolut = df["brand"].value_counts().to_frame()
# Pie chart
absolut.plot(kind='pie', subplots=True, autopct='%1.2f%%',
explode= (0.05, 0.05), startangle=20,
legend=False, fontsize=12, figsize=(8,4))
# Params
plt.xticks(rotation=0, horizontalalignment="center")
plt.title("Brand's distribution", fontsize=10, loc="right");
display(absolut) # Table

图片来源于作者。
3. 我们看到最多用户访问品牌 1 网站的那一天是星期几?
# MySQL
SELECT
date, -- select the date column
DAYNAME(date) AS day_name, -- calculate the day name corresponding to each date
SUM(users) AS users -- sum the number of users for each unique date where the brand is 'Brand 1'
FROM
case_sql -- select data from the case_sql table
WHERE
brand = 'Brand 1' -- filter rows where the brand is 'Brand 1'
GROUP BY 1 -- group the result by date
ORDER BY 3 DESC -- order the result by users in descending order
LIMIT 1; -- select only the first row of the result (the row with the highest number of users)

图片来源于作者。
在 2019 年 9 月至 2020 年 1 月之间的 298,412 名用户中,访问品牌 1网站的用户最多的一天是2019 年 11 月 22 日,共计 885 次访问,那天是周五。
# Python
# filter users that arrived at 'Brand 1' only, assign it 'brand_1'
brand_1 = df[df["brand"] == "Brand 1"].copy()
''' sum total users that came from all "channelgrouping" for the same date,
assign it 'brandgroup' no matter the type of device '''
brandgroup = brand_1.groupby(["date","weekday"])[["default_channel_grouping","users"]].sum()
# filter the date by maximum users, assign it 'users'
users = brandgroup[brandgroup["users"] == brandgroup.users.max()].copy()
# reseat index
users.reset_index(["date"], inplace=True)
users.reset_index(["weekday"], inplace=True)
# results
print(f"""Date: {users.date} \n\nTotal users: {users.users} \n\nDay of week: {users.weekday}""")
Date: 0 2019-11-22
Name: date, dtype: datetime64[ns]
Total users: 0 885
Name: users, dtype: int64
Day of week: 0 Fri
Name: weekday, dtype: object
# calling the variable
users

图片来源于作者。
3.1 在那一天,有多少用户访问了品牌 2?
# MySQL
SELECT
DATE(date) AS date, -- Select the date from the 'date' column and convert it to a date data type
DAYNAME(date) AS dayofweek, -- Select the day of the week from the 'date' column
SUM(CASE
WHEN brand = 'Brand 1' THEN users -- Sum the 'users' column for Brand 1
ELSE NULL
END) AS b1_users,
SUM(CASE
WHEN brand = 'Brand 2' THEN users -- Sum the 'users' column for Brand 2
ELSE NULL
END) AS b2_users
FROM
case_sql -- From the 'case_sql' table
GROUP BY 1, 2 -- Group the results by the first and second columns (date and dayofweek)
ORDER BY 3 DESC -- Order the results by b1_users in descending order
LIMIT 1; -- Limit the results to only the highest total number of Brand 1 users

图片来源于作者。
实际上,两个品牌在同一天都达到了最高访问量。
# Python
# filter users that arrived at 'Brand 2', assign it 'brand_2'
brand_2 = df[df["brand"] == "Brand 2"].copy()
# rename the 'users' column from previous (above) Python code
brandgroup.rename(columns = {'users':'brand1_users'}, inplace = True)
# include a new column with the filtered users from 'Brand_2'
brandgroup["brand2_users"] = brand_2.groupby(["date","weekday"])[["default_channel_grouping","users"]].sum()
# filter the new column (brand2_users) by maximum users
users2 = brandgroup[brandgroup["brand2_users"] == brandgroup.brand2_users.max()].copy()

图片来源于作者。
4. 在所有渠道分组中,哪个渠道贡献了最高数量的用户?
# MySQL
SELECT
default_channel_grouping AS channels,
SUM(users) AS total_users,
ROUND(SUM(users) / (SELECT
SUM(users)
FROM
case_sql) * 100,
1) AS percent -- calculate the percentage of users for each channel
FROM
case_sql
GROUP BY 1
ORDER BY 2 DESC;

图片来源于作者。
自然搜索仍然是生成最多用户的渠道(近 141,000 名用户),占据了两个网站总访问量的近一半,其次是付费搜索和直接访问。展示广告排名第 4,社交媒体排名第 6,共贡献了 6,722 位用户。
# Python
# sum users by all channel groups and plot bar chart
ax = df.groupby("default_channel_grouping")["users"].sum().sort_values(ascending=True)\
.plot(kind="bar", figsize=(9,6), fontsize=12, linewidth=2,
color=sns.color_palette("rocket"), grid=False, table=False)
# show data labels
for p in ax.patches:
ax.annotate("%.0f" % p.get_height(), (p.get_x() + p.get_width() / 2., p.get_height()),
ha='center', va='center', xytext=(0, 7), textcoords='offset points')
# params
plt.xlabel("Channel groups", fontsize=10)
plt.xticks(rotation=90, horizontalalignment="center")
plt.ylabel("Absolute values", fontsize=10)
plt.title("Best channel group (highest number of users)", fontsize=10, loc="right");

图片来源于作者。
4.1 所有渠道分组中,哪个品牌贡献的用户数量最高?
# MySQL
SELECT
default_channel_grouping AS channels,
SUM(CASE -- sum users by brand and map to new columns
WHEN brand = 'brand 1' THEN users -- if brand = 'brand 1', sum users and store in 'Brand_1' column
ELSE NULL -- if not 'brand 1', set value to null
END) AS Brand_1, -- create column for Brand 1 users
SUM(CASE
WHEN brand = 'brand 2' THEN users
ELSE NULL
END) AS Brand_2
FROM
case_sql
GROUP BY 1 -- group by channel
ORDER BY 3 DESC; -- order by Brand 2 users in descending order

图片来源于作者。
# Python
# create pivot_table
# sum all users for each brand by channels
type_pivot = df.pivot_table(
columns="brand",
index="default_channel_grouping",
values="users", aggfunc=sum)
display(type_pivot)
#Display pivot_table with a bar chart
type_pivot.sort_values(by=["Brand 2"], ascending=True).plot(kind="bar", figsize=(12,8) ,fontsize = 15)
plt.xlabel("Channel groups", fontsize=10)
plt.xticks(rotation=90, horizontalalignment="center")
plt.ylabel("Absolute values", fontsize=10)
plt.title("Channel groups by brand (highest number of users)", fontsize=10, loc="right");

图片来源于作者。

图片来源于作者。
自然搜索为品牌 2 带来了 105,062 位用户,为品牌 1 带来了 35,911 位用户。除‘其他’之外,品牌 1 在这一方面表现更优,品牌 2 在所有渠道中为网站带来的用户数量最高。
5. 在所有渠道中,哪个品牌在 2019 年贡献了至少 5% 的付费会话?
# MySQL
SELECT
brand,
default_channel_grouping AS channels,
ROUND(SUM(sessions) / (SELECT
SUM(sessions)
FROM
case_sql) * 100,
1) AS percent
FROM
case_sql
WHERE
default_channel_grouping IN ('Paid Search' , 'Paid Social', 'Display', 'Other Advertising') -- include only rows with these values
AND date < '2020-01-01' -- only date before '2020-01-01' will be included.
GROUP BY 1 , 2
HAVING percent > 5 -- filters the groups to only include values greater than 5%.
ORDER BY 1 , 3 DESC

图片来源于作者
# Python
# groupby dataframe by selected cols
df = df.groupby(["date","brand","default_channel_grouping"])["sessions"].sum().to_frame().copy()
# calculate percentages (new column)
df["percent"] = (df.apply(lambda x: x/x.sum())*100).round(2)
# reset index
df = df.reset_index().copy()
# display a 5 rows sample
df.sample(5)

图片来源于作者。
# filter paid channels using lambda function
paid = df.apply(lambda row: row[df['default_channel_grouping'].isin(['Display','Paid Search','Paid Social','Other Advertising'])])
# filter year 2019
paid = paid[paid['date'] < '2020-01-01']
# groupby channels by brand
paid = paid.groupby(["brand","default_channel_grouping"])[["sessions","percent"]].sum()
# filter sessions higher than 5%
paid[paid["percent"] >5]

图片来源于作者。
6. 两个品牌按设备类型分别收到多少次访问?
# MySQL
SELECT
brand,
SUM(CASE
WHEN device_category = 'Desktop' THEN users
ELSE NULL
END) AS desktop,
SUM(CASE
WHEN device_category = 'Mobile' THEN users
ELSE NULL
END) AS mobile,
SUM(CASE
WHEN device_category = 'Tablet' THEN users
ELSE NULL
END) AS tablet
FROM
case_sql
GROUP BY 1
ORDER BY 1;

图片来源于作者。
# Python
# pivot_table
type_pivot = df.pivot_table(
columns="device_category",
index="brand",
values="users", aggfunc=sum)
display(type_pivot)
# display pivot_table (chart)
ax = type_pivot.sort_values(by=["brand"], ascending=False).plot(kind="bar", figsize=(12,8) ,fontsize = 15);
# adding data labels
for p in ax.patches:
ax.annotate("%.0f" % p.get_height(), (p.get_x() + p.get_width() / 2., p.get_height()), ha='center', va='center', xytext=(0, 7), textcoords='offset points')
plt.xlabel("Brands", fontsize=10)
plt.xticks(rotation=0, horizontalalignment="center")
plt.ylabel("Absolute values", fontsize=10)
plt.title("Brand by type of device", fontsize=10, loc="right")
plt.legend(["Desktop","Mobile","Tablet"]);

图片来源于作者。
移动设备 是品牌 2 的首选设备类型,而桌面设备 是品牌 1 使用最多的设备。
6.1 用户平均设备使用类型按渠道分布如何?
# MySQL
SELECT
default_channel_grouping,
AVG(CASE
WHEN device_category = 'Desktop' THEN users
ELSE NULL
END) AS desktop,
AVG(CASE
WHEN device_category = 'Mobile' THEN users
ELSE NULL
END) AS mobile,
AVG(CASE
WHEN device_category = 'Tablet' THEN users
ELSE NULL
END) AS tablet
FROM
case_sql
GROUP BY 1
ORDER BY 1;

图片来自作者。
# Python
# pivot_table
type_pivot = df.pivot_table(
columns="device_category",
index="default_channel_grouping",
values="users", aggfunc=np.mean)
display(type_pivot)

图片来自作者。
# display pivot_table
type_pivot.sort_values(by=["default_channel_grouping"], ascending=False).plot(kind="bar", figsize=(12,8) ,fontsize = 15);
plt.xlabel("Date", fontsize=10)
plt.xticks(rotation=90, horizontalalignment="center")
plt.ylabel("Absolute values", fontsize=10)
plt.title("Average use of device types by channel grouping", fontsize=10, loc="right")
plt.legend(["Desktop","Mobile","Tablet"]);

图片来自作者。
平均而言,桌面设备在推荐、直接和其他渠道中使用更频繁。至于其他渠道,垂直导向的内容应该始终考虑在内。
7. 如何评估渠道分组的跳出率?
跳出率的计算方法是将总跳出次数除以总会话次数。
# MySQL
SELECT
default_channel_grouping,
SUM(sessions) AS sessions,
SUM(bounces) AS bounces,
ROUND(SUM(bounces) / SUM(sessions) * 100, 2) AS bounces_r
FROM
case_sql
GROUP BY 1
ORDER BY 4 DESC;

图片来自作者。
平均跳出率:54.93% (avg_bounces_r)
SELECT
SUM(sessions) AS sessions,
SUM(bounces) AS bounces,
ROUND(SUM(bounces) / SUM(sessions) * 100, 2) AS bounces_r,
AVG(ROUND(bounces/sessions*100, 2)) AS avg_bounces_r
FROM
case_sql;
# Python
# group individual channels by sum of users
dfbounce = df.groupby("default_channel_grouping")["users"].sum().to_frame()
# group individual channels by sum of sessions
dfbounce["sessions"] = df.groupby("default_channel_grouping")["sessions"].sum()
# group individual channels by sum of bounces
dfbounce["bounces"] = df.groupby("default_channel_grouping")["bounces"].sum()
# calculus of bounce rate for each individual channel
dfbounce["bounces_r"] = dfbounce.apply(lambda x: 0.0 if x["sessions"] == 0.0 else (x["bounces"] / x["sessions"])*100, axis=1).round(2)
dff = dfbounce.copy()
dfbounce.drop(["users"],axis=1,inplace=True)
# sort values by rate
dfbounce.sort_values(by="bounces_r", ascending=False)

图片来自作者。
# display bar chart with the bounce rate for each channel
ax = dfbounce.groupby("default_channel_grouping")["bounces_r"].sum().sort_values(ascending=True)\
.plot(kind="bar", figsize=(9,6), fontsize=12, linewidth=2, color=sns.color_palette("rocket"), grid=False, table=False)
for p in ax.patches:
ax.annotate("%.2f" % p.get_height(), (p.get_x() + p.get_width() / 2., p.get_height()),
ha='center', va='center', xytext=(0, 7), textcoords='offset points')
plt.axhline(dfbounce.groupby("default_channel_grouping")["bounces_r"].mean().mean(), linewidth=1, color ="r")
plt.xlabel("channel groups", fontsize=10)
plt.xticks(rotation=90, horizontalalignment="center")
plt.ylabel("Absolute values", fontsize=10)
plt.title("Bounce rate by channelGrouping", fontsize=10, loc="right");

图片来自作者。
正如预期,显示渠道的跳出率最高,其次是直接渠道和付费社交。自然搜索的跳出率与平均水平持平。低于最低跳出率阈值的有推荐、原生广告和其他广告。
7.1 网站上的跳出率是否随着时间的推移有所改善或恶化?
# MySQL
SELECT
YEAR(date) AS year, -- extract year
MONTH(date) AS month, -- extract month
DATE_FORMAT(date, '%b') AS month_, -- format the date column to display month name
ROUND(SUM(bounces) / SUM(sessions) * 100, 2) AS bounces_r -- calculate bounce rate
case_sql
GROUP BY 1 , 2 , 3
ORDER BY 1 , 2 , 3;

图片来自作者。
# Python
df_date = df.groupby("date")[['sessions','bounces']].sum()
''' create function to assess the bounce rate, assign it as 'bounce_r'
Return 0 if session's value is 0, else divide the bounces by sessions
for each date and multiply it by 100 to get the percentage '''
def div(bounces, sessions):
return lambda row: 0.0 if row[sessions] == 0.0 else float((row[bounces]/(row[sessions])))*100
# create column 'bounce_r' with the function results
df_date["bounce_r"] = (df_date.apply(div('bounces', 'sessions'), axis=1)).round(1)
# drop unnecessary columns
df_date.drop(["sessions","bounces"], axis=1, inplace=True)
# sum all bounces over time and plot chart
ax = df_date.plot(kind="line", figsize=(14,6), fontsize=12, linewidth=2)
plt.xlabel("Date", fontsize=10)
plt.xticks(rotation=90, horizontalalignment="center")
plt.ylabel("Rate", fontsize=10)
plt.title("Evolution of the bounce rate over time", fontsize=10, loc="right");

图片来自作者。
# Smoothing the line with a step of 15 days interval
resampled = df_date["bounce_r"].resample("m").mean()
plt.figure(figsize = (12,6))
ax = sns.lineplot(data = resampled)
plt.title("Evolution of the bounce rate over time (smooth)", fontsize=10, loc="right")
plt.xlabel("Date", fontsize=10)
plt.xticks(rotation=0, horizontalalignment="center")
plt.ylabel("Rate", fontsize=10);

图片来自作者。
网站上的跳出率正在改善。
7.2 跳出率按渠道和品牌的细分
# Python
# filter by brand
b1 = df[df["brand"] == "Brand 1"]
b2 = df[df["brand"] == "Brand 2"]
# ** brand 1 **
# group individual channels by sum of sessions for brand 1
dfbrand = b1.groupby("default_channel_grouping")["sessions"].sum().to_frame()
dfbrand.rename(columns={"sessions":"sessions1"}, inplace=True)
# group individual channels by sum of bounces for brand 1
dfbrand["bounces1"] = b1.groupby("default_channel_grouping")["bounces"].sum()
# calculus of bounce rate for each individual channel for brand 1
dfbrand["1bounces_r"] = dfbrand.apply(lambda x: 0.0 if x["sessions1"] == 0.0 else (x["bounces1"] / x["sessions1"]*100), axis=1).round(2)
# ** brand 2 **
# group individual channels by sum of bounces for brand 2
dfbrand["sessions2"] = b2.groupby("default_channel_grouping")["sessions"].sum()
# group individual channels by sum of bounces for brand 2
dfbrand["bounces2"] = b2.groupby("default_channel_grouping")["bounces"].sum()
# calculus of bounce rate for each individual channel for brand 2
dfbrand["2bounces_r"] = dfbrand.apply(lambda x: 0.0 if x["sessions2"] == 0.0 else (x["bounces2"] / x["sessions2"]*100), axis=1).round(2)
# sort values by rate
dfbrand.sort_values(by="1bounces_r", ascending=False)

图片来自作者。
# clean dataframe
dfchannels = dfbrand.copy()
dfbrand_chart = dfbrand.copy()
dfbrand_chart.drop(["sessions1","sessions2","bounces1","bounces2"], axis=1, inplace=True)
# display bar chart with the average bounce rate for each channel
ax = dfbrand_chart.plot(kind="bar", figsize=(13,6), fontsize=12, linewidth=2, color=sns.color_palette("BrBG"), grid=False, table=False)
for p in ax.patches:
ax.annotate("%.1f" % p.get_height(), (p.get_x() + p.get_width() / 2., p.get_height()), ha='center', va='center', xytext=(0, 7), textcoords='offset points')
plt.xlabel("channel groups", fontsize=10)
plt.xticks(rotation=90, horizontalalignment="center")
plt.ylabel("Absolute values", fontsize=10)
plt.title("Bounce rate by channelGrouping and by brand", fontsize=10, loc="right")
plt.legend(["Brand 1","Brand 2"]);

图片来自作者。
品牌 1 的显示渠道跳出率低于预期。通常,这些渠道会导致较高的跳出率。需要详细了解内容策略,并尝试将其调整到品牌 2。
品牌 2 在推荐渠道中的跳出率高于可接受范围。
8. 入站媒体与付费媒体的比例
# MySQL
SELECT
brand,
CASE
WHEN
default_channel_grouping IN ('Paid Search',
'Paid Social',
'Display',
'Other Advertising')
THEN
'Paid'
WHEN
default_channel_grouping IN ('Direct',
'Native',
'Organic Search',
'Referral',
'Social',
'Email',
'(Other)')
THEN
'Organic'
ELSE NULL
END AS media,
ROUND(SUM(bounces) / SUM(sessions) * 100, 2) AS bounce_r
FROM
case_sql
GROUP BY brand , media
ORDER BY 1;

图片来自作者。
# Python
# create dictionary
media_dict =
{
'Display': 'paid',
'Paid Search': 'paid',
'Paid Social': 'paid',
'Other Advertising': 'paid',
'Direct': 'organic',
'Native': 'organic',
'Organic Search': 'organic',
'Referral': 'organic',
'Social': 'organic',
'Email': 'organic',
'(Other)': 'organic'
}
# mapping the dict into a new column
df['media'] = df['default_channel_grouping'].map(media_dict)
# define cols position in dataframe
cols = ['brand','media','sessions','bounces']
# reindex columns order
df = df.reindex(columns = cols)
# groupby dataframe by selected cols
df = df.groupby(["brand","media"])[["sessions","bounces"]].sum()
# bounce rate by channel
df["bounces_r"] = df.apply(lambda x: 0.0 if x["sessions"] == 0.0 else (x["bounces"] / x["sessions"])*100, axis=1).round(2)

图片来自作者。
结论
正如承诺的,我们通过逐步的方法进行了简单的数字营销分析,使用了 MySQL Workbench 和 Python。
两种工具各有其特点和要求,但推理过程相对类似,抛开它们的图形能力和局限性。
欢迎下载数据集并通过实践本文中涉及的一些技术细节来探索,实施新代码并提出进一步的分析问题。
探索你可能也喜欢的其他项目:
在 MySQL Workbench 中审查一些应用于营销操作和常见问题的主要 SQL 查询的方法……
towardsdatascience.com ## 15 个关于移动营销活动的商业问题:ROAS(广告支出回报)
一项探索性营销数据分析,用于监控和评估移动营销活动的表现(EDA)
towardsdatascience.com ## 机器学习:预测银行贷款违约
一种数据科学方法,用于预测和理解申请人的档案,以最小化未来贷款违约的风险。
towardsdatascience.com
如何与我联系:
✅ 感谢阅读!
Dijkstra 算法在 OSM 网络中按旅行时间加权
原文:
towardsdatascience.com/dijkstras-algorithm-weighted-by-travel-time-in-osm-networks-792aa92e03af
使用 OSMNX 1.6 寻找最快和最短路径
·发表于 Towards Data Science ·7 min 阅读·2023 年 10 月 10 日
--

图片由作者提供。摩洛哥示例中的最快路线(红色)和最短路线(橙色)
最短路径(Dijkstra)算法可以应用于 OSM 网络中,如驾驶、骑行和步行,以找到起点和终点之间的最优路线。但该算法在网络中计算基于距离的最短路线,这并不意味着最优路线。在道路网络中,距离可以是相对的,当我们考虑道路的速度时。显然,在所有道路速度相等的情况下,两点之间的最优路线可能是最短的。如果我们比较高速公路与城市街道的速度,我们会重新调整这个想法,理解最优路线是最快的。
“在道路网络中,距离可能是相对的,当我们考虑道路的速度时”
借助 Python 库 OSMNX,可以在全球范围内为不同类型的道路添加速度,并计算 OSM 网络中节点之间的旅行时间。这使得 Python 库可以处理以旅行时间加权的最短路径算法。
这一实践是之前一个教程的延续,那个教程使用了最短路径算法来计算摩洛哥两个位置之间的最短路线。
摩洛哥 OSM 网络中的最短路线
## 最短路径(Dijkstra)算法逐步 Python 指南
使用 OSMNX 1.6 的更新及长距离路径
towardsdatascience.com
访问编码教程
如果你还不是Medium的会员,你需要订阅才能访问这些故事。你可以通过使用我的个人链接来跟随更多编码教程并支持我的工作。成为这段编码之旅的一部分。
这里加入 👉
bit.ly/3yjLsSL
OSM 数据许可证
- Open Street Map 数据。 许可于 Open Data Commons Open Database License (ODbl) 或署名许可。用户可以自由复制、分发、传输和调整数据,只要注明作者,如© OpenStreetMap 贡献者。
介绍
接下来的步骤将指导如何使用旅行时间应用最短路径。我们将比较最快路线和最短路线,以了解旅行时间和长度的变化。
此外,我们还将使用 OSMNX 中的更多函数来改进结果,如utils_graph.route_to_gdf(),以及计算旅行时间的add_edge_speeds()和add_edge_travel_times()。
这是由Hanae建议的起点和终点的快速视图。

作者提供的图像。起点和终点位置。
编码实践
开始获取我们需要的库。
import osmnx as ox
import geopandas as gpd
from shapely.geometry import Point
import pandas as pd
import matplotlib.pyplot as plt
1. 定义起点和终点为 GeoDataFrames
开始添加坐标并使用 Geopandas 创建新的 GDF。
# --- origin and destination geom
origin_geom = Point(-5.6613932957355715, 32.93210288339607)
destination_geom = Point(-3.3500597061072726, 34.23038027794419)
# --- create origin dataframe
origin = gpd.GeoDataFrame(columns = ['name', 'geometry'], crs = 4326, geometry = 'geometry')
origin.at[0, 'name'] = 'origin'
origin.at[0, 'geometry'] =origin_geom
# --- create destination dataframe
destination = gpd.GeoDataFrame(columns = ['name', 'geometry'], crs = 4326, geometry = 'geometry')
destination.at[0, 'name'] = 'destination'
destination.at[0, 'geometry'] = destination_geom
2. 获取图网络
当我们有长途路线时,建议使用envelope函数来获取图。
使用之前定义的函数
def get_graph_from_locations(origin, destination, network='drive'):
'''
network_type as drive, walk, bike
origin gdf 4326
destination gdf 4326
'''
# combine and area buffer
combined = pd.concat([origin, destination])
convex = combined.unary_union.envelope # using envelope instead of convex, otherwise it breaks the unary_union
graph_extent = convex.buffer(0.02)
graph = ox.graph_from_polygon(graph_extent, network_type= network)
return graph
应用并可视化
# --- Get Graph
graph = get_graph_from_locations(origin, destination)
fig, ax = ox.plot_graph(graph, node_size=0, edge_linewidth=0.4, bgcolor='black', edge_alpha=0.2, edge_color='yellow')

作者提供的图像。包含起点和终点的道路网络图
3. 向道路网络添加旅行时间
我们将使用函数add_edge_speeds()添加旅行时间,该函数输入速度(以 km/h 为单位)。插补将按高速公路类型添加道路的平均最大速度值。然后,我们使用函数add_edge_travel_time()计算旅行时间
我们将保留相同的变量graph
# --- add edge speed
graph = ox.add_edge_speeds(graph)
# --- add travel time
graph = ox.add_edge_travel_times(graph)
如果你想修改高速公路类别,可以传递一个基于本地速度值的字典
# --- add speeds define by local authorities (example)
hwy_speeds = {"residential": 35, "secondary": 60, "tertiary": 75}
graph = ox.add_edge_speeds(graph, hwy_speeds)
graph = ox.add_edge_travel_times(graph)
现在,通过获取边缘类别来快速查看按高速公路类型的旅行时间。
# --- get the edges as GDF
edges = ox.graph_to_gdfs(graph, nodes=False)[['highway', 'speed_kph', 'length', 'travel_time', 'geometry']].reset_index(drop=True)
# --- see mean speed/time values by road type
edges["highway"] = edges["highway"].astype(str)
edges.groupby("highway")[["speed_kph", "travel_time"]].mean().round(0)

作者提供的图像。道路网络中的速度和旅行时间
4. 查找起点和终点的最近节点
使用起点和终点坐标获取网络中最接近的节点。该函数在新版本 1.6 中为nearest_nodes()
# ------------- get closest nodes
# origin
closest_origin_node = ox.nearest_nodes(G=graph,
X=origin_geom.x,
Y=origin_geom.y)
# destination
closest_destination_node = ox.nearest_nodes(G=graph,
X=destination_geom.x,
Y=destination_geom.y)
然后,我们在节点之间应用最短路径算法。
5. 计算使用旅行时间的最短路径
我们将使用 shortest_path() 函数来计算我们的路线。我们将同时使用距离和时间来比较路线在 weight 参数下的不同之处。
# --- calculate shortest path with length and travel time
# time
fastest_route = ox.shortest_path(graph,
orig = closest_origin_node,
dest = closest_destination_node,
weight="travel_time")
# distance
shortest_route = ox.shortest_path(graph,
orig = closest_origin_node,
dest = closest_destination_node,
weight="length")
这将返回一组属于该路线的节点代码。

作者提供的图像。路线的节点
6. 从节点创建路线(单行代码)
osmnx 实现了一个函数 utils_graph.route_to_gdf(),可以在一行中将节点转换为 GeoDataFrame。很方便,我们可以动态获取感兴趣的列。
# --- get gdf of routes
# fastest
fastest_route_gdf = ox.utils_graph.route_to_gdf(graph, fastest_route, weight='travel_time')[['highway', 'speed_kph', 'travel_time', 'geometry']]
# shortest
shortest_route_gdf = ox.utils_graph.route_to_gdf(graph, shortest_route, weight='length')[['highway', 'speed_kph', 'travel_time', 'geometry']]
7. 快速比较(时间和距离)
我们将比较两条路线的旅行时间和长度。
# --- comparison
d1 = fastest_route_gdf['length'].sum()
d2 = shortest_route_gdf['length'].sum()
t1 = fastest_route_gdf['travel_time'].sum()
t2 = shortest_route_gdf['travel_time'].sum()
打印值
print(f'Fastest Route: Time {round(t1/3600, 2)} hours, Distance {round(d1/1000, 2)} km')
print(f'Shortest Route: Time {round(t2/3600, 2)} hours, Distance {round(d2/1000, 2)} km')
*最快路线:时间 5.42 小时,距离 378.93 公里
最短路线:时间 5.6 小时,距离 362.84 公里*
结果显示最快的路线更长。如预期的那样,涉及速度时,距离变得相对。
8. 保存文件并可视化
保存网络和路线
# --- save
edges.to_file('osm_drive_network.gpkg')
fastest_route_gdf.to_file('fastest_route.gpkg')
shortest_route_gdf.to_file('shortest_route.gpkg')
在 QGIS 中

作者提供的图像。最快的路线(橙色)和最短的路线(黄色)
在 Matplotlib 中
# --- plot network
ax = edges.plot(figsize=(12, 10), linewidth = 0.1, color='grey', zorder=0);
# --- origin and destination
origin.plot(ax=ax, markersize=100, alpha=0.8, color='blue', zorder=1)
destination.plot(ax=ax, markersize=100, alpha=0.8, color='green', zorder=2)
# --- route
fastest_route_gdf.plot(ax=ax, linewidth = 4, color='red', alpha=0.4, zorder=3)
shortest_route_gdf.plot(ax=ax, linewidth = 4, color='yellow', alpha=0.4, zorder=4)
plt.axis(False);

作者提供的图像。较快的路线(红色)和最短的路线(黄色)
已知改进
从 osmnx 使用的新功能 utils_graph.route_gdf() 创建了一个干净的路线。它使用了道路段,而不仅仅是节点之间的联接,新路径覆盖了 OSM 道路网络。

作者提供的图像。路线覆盖了道路网络。
结论
最短路径算法可以使用 OSMNX 函数 add_edge_speeds() 和 add_edge_travel_times() 计算道路速度。这种不同的方法表明,如果在路线计算中实施速度(旅行时间),最短路径是相对的。正如预期的那样,最快的路线最终比最短路线更长,但它以最短的旅行时间到达了目的地。
生成覆盖道路网络的路线的改进使得在城市区域级别的可达性和邻近性研究中,距离和旅行时间的计算更加准确。
致谢
多亏了Geoff Boeing提供的资料,我得以探索这些功能并理解 OSMNX 的功能。
如果你想就问题或定制分析联系我:
维度缩减:面对维度诅咒
PCA 与动态因子模型的比较
·
跟进 发表在 Towards Data Science ·10 分钟阅读·2023 年 4 月 13 日
--
Kolleen Gladden 的照片,来自 Unsplash
许多数据科学家不得不面对维度的挑战。数据集可能包含大量变量,使得理解和计算变得复杂。例如,资产管理者可能会被与其投资组合相关的许多动态变量所困扰,处理大量数据可能导致计算问题。降维是一种将大量变量的信息提取到较小的降维变量集合中的方法,而不会丧失过多的解释性。换句话说,降维方法可以被认为是寻找一个最小化重构误差的子空间。
存在几种方法来进行信息提取,每种方法都适用于不同的用例。本文旨在提供这两种方法的详细比较:主成分分析(PCA)和动态因子模型(DFM)。PCA 可以用于任何类型的结构化数据集,而动态因子模型则用于时间序列应用,因为它嵌入了时间序列的演变。
分析基于经济和金融数据。用于本研究的数据是克拉克、托德;卡里耶罗、安德烈亚;马切利诺、马西米利亚诺的文章测量不确定性及其对经济的影响中使用数据的复制版,数据可在Harvard dataverse上获取。数据包括 18 个宏观经济变量和 12 个金融变量,涵盖了这些变量从 1960 年到 2014 年的演变。在通过降维算法处理之前,数据被转换以确保平稳性。
整个代码可在Github上获取。
主成分分析(PCA)
理论
PCA 可以看作是一种无监督的降维方法。假设我们有大量的变量。所有这些变量似乎都对分析有用,但没有明显的方法将这些变量汇总成类别。在这种情况下,算法将负责在没有模型师特定输入的情况下进行降维。换句话说,算法将创建更少的变量,称为降维成分,这些成分能够接近地重现初始变量。
PCA 的方法基于变量的协方差。如果两个变量高度协方差,这意味着它们遵循相同的趋势。第一个变量在重现第二个变量方面非常高效,使得只保留第一个变量而不丧失在需要时重建第二个变量的能力成为可能。PCA 创建一个变量子集,最大化与初始变量集的协方差,以便在较低维度中存储尽可能多的信息。
该方法的思路是计算由原始变量集创建的空间的正交基。创建这个基的向量是方差-协方差矩阵的特征向量。通过选择最能代表初始数据的特征向量,即包含最多协方差的特征向量,可以轻松地减少维度。特征值量化了向量存储的协方差量:特征值越大,其相关的向量就越有趣。
PCA 算法的过程如下:
1. 计算协方差矩阵

2. 计算其特征向量和特征值
3. 对特征值进行排序,以保留包含最多信息的向量
每个特征值与所有特征值之和的比率表示其相关特征向量中包含的协方差量。剩下的任务是确定保留的特征向量数量。我们将在下一节中看到为此选择的不同参数。
应用到数据
Python 使得定义 PCA 模型变得简单,因为它包含在库 sklearn 中。属性 n_components 可以初步设置为一个较大的值,以便比较特征值,然后选择保留的组件数量。一旦拟合,特征值将按降序显示,以帮助我们做出决策。下面的图显示了每个特征值所包含的协方差比率。
from sklearn.decomposition import PCA
pca = PCA(n_components=5).fit(u_data)
plt.plot(pca.explained_variance_ratio_)

选择合适特征向量数量的通常规则是查看图中表示的“肘部”。取到肘部的向量数量提供了信息保留和结果维度之间的有趣折衷。在这种情况下,我们保留前两个组件。
前两个组件的协方差比率为 69.6% 和 9.7%。因此,通过仅保留两个组件,我们保留了初始数据中几乎 80% 的信息,同时将维度从 30 减少到 2!
总结来说,PCA 是一种很好的降维工具。它易于部署,并且在保留信息方面产生了良好的结果,同时显著减少了维度。然而,PCA 像一个黑箱,阻止了对结果组件的有意义理解。此外,PCA 适用于任何类型的结构化数据,但如果数据以时间序列的形式存在,则不包含数据的动态性。
下一节将讨论动态因子模型,它们可能是应对这些局限性的潜在解决方案。
动态因子模型(DFM)
理论
动态因子模型用于观察 N 个变量随时间的演变(这些变量组合成一个向量 Xt),并使用较少数量的动态公共因子。这种方法的优势在于它将大量变量的共同运动嵌入到较少的成分中。
这种方法适用于时间序列应用。因此,它们在金融和经济学中被广泛使用,因为许多关键变量随时间共同演变。
DFM 将向量 Xt 定义为减少因子(ft)过去和当前值的线性组合。这些因子本身是动态的,即以自回归方式定义。减少的成分数量为 q,自回归的滞后为 p。

每个 λ 是一个 (N x q) 矩阵,其中 q 是减少的成分数量,每个 ft 是一个 (q x 1) 向量,每个 ψ 是一个 (q x q) 矩阵。动态性体现在每个减少的向量 ft 遵循向量自回归过程,因此它本身是基于 f 的过去值计算的。此外,向量 X 受当前和过去数据的影响。
DFM 的一个非常重要的方面是组件的数量是在计算前基于对数据的定性知识定义的。如果变量容易分类,这可以是一个有趣的特征,但如果没有出现有意义的类别,这也可能是一个挑战。
一旦因子的数量被定义,估计成分的主要方法是使用高斯最大似然估计器(MLE)。ε 和 η 被假设为遵循高斯分布,MLE 的目标是通过调整高斯参数(均值和标准差)来最大化获得样本数据(Xt, ft)的概率。幸运的是,这一步骤在 Python 库中直接实现,使得计算变得容易。
一旦估计完成,这些计算出的成分将代表它们被分配的类别。这就意味着我们得到的成分数量与我们定义的类别数量相同。这使我们能够以高效且有意义的方式减少维度。
数据应用
DFM 将应用于与之前展示的相同数据集。这里有个好消息:我们直接有两个明显的类别:宏观经济学和金融。
Python 的 statsmodels 库中包含了一个 DFM 模型:DynamicFactorMQ。为了计算模型,需要几个参数。首先,显然是我们旨在减少的初始数据。其次,一个将每个变量与其类别关联起来的字典(从技术上讲,每个变量可以属于多个类别,但我们在这里不讨论这种情况)。
factors = dict()
for macro_variable in list(macro_variables.values()):
factors[macro_variable] = ["Macro"]
for finance_variable in list(finance_variables.values()):
factors[finance_variable] = ["Finance"]
然后,我们定义与每个因子 ft 相关的 VAR 模型的滞后阶数,即多少个时间步骤向后影响因子的当前状态。在我们的案例中,一个滞后似乎足够。增加滞后显然会增加计算约束,但通过在每一步提供更长时间的信息,可以显著影响模型的效率。
最后,需要定义特定成分。这个成分表示向量 Xt 中不能通过 ft 的当前值和过去值解释的部分。这个成分可以看作是线性回归中的残差。它可以拟合为 AR(1)模型或白噪声。从经济学角度来看,这一选择是重要的:我们估计模型的残差是自回归的(即现值和过去值相关)还是独立同分布的?对于经济学研究来说,一个不相关的特定成分通常是不现实的,因为测量方法通常会引入相关误差。
factor_model = DynamicFactorMQ(u_data,
factors=factors,
factor_orders = {'Macro': 1, "Finance": 1},
idiosyncratic_ar1=True,
standardize=False)
model_results = factor_model.fit(disp=30)
方法比较
接下来的问题显然是:应该使用哪种方法?如预期的那样,这取决于我们要寻找的内容。
让我们总结一下每个模型的优缺点。
主成分分析(PCA)
-
可以应用于任何类型的结构化数据
-
计算时无需对数据有先验知识
-
选择降维成分的经验法则
-
无监督过程
动态因子模型(DFM)
-
应用于时间序列数据
-
对数据的定性知识,以确定嵌入在降维因子中的类别
-
预先确定的降维成分数量
乍一看,PCA 似乎比 DFM 更受关注,但要做出决定还需要进一步观察。这两者之间的主要区别在于 DFM 能够提供其结果的有意义解释。
可读性
首先,我们来看一下创建的组件。


这两个图显示了每个模型中两个选择因子的演变。有趣的是,这两个模型似乎都将一个变量分离为趋势(蓝色)和另一个变量分离为波动性(橙色)。DFM 给我们提供了这一观察结果背后的含义:看到宏观变量(例如 GDP、房价等)随着时间的推移而增加并不奇怪。此外,金融变量被认为波动性更大。PCA 似乎捕捉到了相同类型的信息,但我们仍然只能对这种现象做出假设。DFM 在这一点上有优势。
准确性
让我们回到降维方法的目的:作为较低维度下原始数据的良好替代。因此,我们需要确保模型能够准确地重现原始数据。
Python 为这两种算法提供了一种便捷的方法来重现初始变量。对于 PCA,将数据转换为其降维空间后,inverse_transform 方法提供了由模型处理的每个初始变量的表示。DFM 模型将所有表示包含在其 fittedvalues 属性中。
#PCA
scores = pca.transform(u_data)
reconstruct = u_data + pca.inverse_transform(scores) - u_data
#DFM
model_results.fittedvalues
我们可以轻松绘制每个模型的数据表示。下面的图中,我们展示了失业率变量的一个例子。

在这个例子中,DFM 显然更适合,因为它始终更接近原始数据,变化更少。为了进行更全面和定量的评估,让我们计算两个模型在整个数据集上的残差。
print(f"Residuals of DFM on global dataset: {np.round(np.abs(model_results.resid).sum().sum(), 2)}")
print(f"Residuals of PCA on global dataset: {np.round(np.abs(resid_pca).sum().sum(), 2)}")

残差和
在重现初始数据方面,DFM 明显比 PCA 更具性能。模型中的分类和动态似乎准确捕捉了初始变量集的信息。
结论
我们比较了两种降维方法,各有优缺点。我们看到,在所呈现的情况下,DFM 模型更适合,但 PCA 也非常有价值。让我们总结一下:
何时偏好 PCA?
-
数据中没有时间动态。
-
初始数据没有明显的分类。
-
对初始数据的定性知识很少。
何时偏好 DFM?
-
时间动态是数据的一个重要特征。
-
分析需要对降维组件的理解。
-
数据的分类很容易找到。
总结来说,没有哪种算法在所有情况下都优于另一种。建模者的角色是评估每种情况中什么是最好的。此外,正如我们所见,两种模型都易于在 Python 上实现。实现这两者有助于增加对数据的理解,并带来更好的解决方案。
我希望这篇文章对你有所帮助,并能帮助你理解这两种模型之间的差异。请随时给我任何反馈或想法!
参考文献
Clark, Todd; Carriero, Andrea; Marcellino, Massimiliano, 2017, “Replication Data for: “Measuring Uncertainty and Its Impact on the Economy””, doi.org/10.7910/DVN/ENTXDD, Harvard Dataverse, V3
DINO — 计算机视觉的基础模型
原文:
towardsdatascience.com/dino-a-foundation-model-for-computer-vision-4cb08e821b18
🚀Sascha 的论文俱乐部
自监督视觉变换器中的新兴特性,作者 M. Caron 等。
·发表于 Towards Data Science ·13 分钟阅读·2023 年 9 月 27 日
--
计算机视觉正迎来一个令人兴奋的十年。来自自然语言领域的巨大成功被转移到视觉领域,包括引入 ViT(视觉变换器),最近大规模的自监督预训练技术在基础模型的名义下成为头条新闻。
今天我们将探讨一个名为 DINO(自DI蒸馏,NO 标签)的框架,它是建立在 ViTs 有趣特性基础上的视觉基础模型。它也是今天表现最佳的基础模型之一的前身:DINOv2。

图片来源于 出版物,作者 Sascha Kirch
论文: 自监督视觉变换器中的新兴特性,作者 Mathilde Caron 等,2021 年 4 月 29 日
类别: 基础模型,计算机视觉,视觉变换器,知识蒸馏,相似性学习,自监督学习
[BYOL] — [CLIP] — [GLIP] — [Segment Anything] — [DINO] — [Depth Anything] — [DDPM]
大纲
-
背景与背景
-
方法
-
实验
-
消融测试
-
结论
-
进一步阅读与资源
背景与背景
时间是 2021 年,准确地说是 4 月。自从发布了带有 Attention is All You Need 的 Transformer 模型已经过去了四年。自监督预训练在 NLP 中已经由 BERT 等模型长期实践,而“基础模型”这一术语在接下来的几个月中尚未被知晓,直到 “关于基础模型的机遇与风险” 的发布。六个月前,Vision Transformer (ViT) 首次发布在 arxiv 上,距离 ICLR 2021 还有一个月,它将在那里进行展示。
让我们稍微消化一下这个信息:ViT 于 2020 年 10 月在 arxiv.org 上首次发布,并在 2021 年 5 月的 ICLR2021 上进行了展示。DINO 于 2021 年 4 月在 arxiv 上发布。所以,实际在会议上展示前的一个月。这意味着他们只有 5 个月的时间,如果他们立即开始的话,来构思项目的想法、组建团队、奠定理论基础、训练模型、进行实验和消融测试,并撰写论文。难怪现在的博士生感到不断的焦虑。至少这就是我有时的感受 😅。
尽管 ViT 与卷积网络非常具有竞争力,但它们在计算资源和训练数据量方面的要求很高。
DINO 的作者做出了一个简单的观察:变换器在 NLP 中的成功与自监督预训练相关,而目前视觉领域的自监督方法是基于卷积网络的,比如 BYOL。
论文分析——《Bootstrap Your Own Latent: A New Approach to Self-Supervised Learning》
[towardsdatascience.com
受到 BYOL 和 mean teacher 的启发,作者提出了一个框架来以自监督的方式训练 ViT,并发现:
-
自监督 ViT 特征明确包含场景布局,特别是对象边界。
-
自监督 ViT 特征在没有任何微调、线性分类器或数据增强的情况下,与基础的最近邻分类器 (k-NN) 一起表现尤为出色。
与 BYOL 和 mean teacher 相比,DINO 实现了一个知识蒸馏框架,包括一个学生模型和一个教师模型,作用于同一图像的不同视角,并采取额外措施应对相似性学习方法的固有不稳定性,其中解决方案通常会崩溃。
底层视觉变换器架构 (ViT) 的一个有趣发现是,当使用无监督学习技术进行训练时,其特征包含有关图像语义分割的显著信息。可以简单地可视化多头注意力层中选择的头部的自注意力图,如下方视频所示:

图 1:选择的头部的自注意力图。来源
让我们深入探讨一下 DINO 实现其框架的方式,如何应对不稳定性,以及与以前的方法相比它的表现如何!

由 Sascha Kirch 进行的论文解读
查看列表7 个故事!“DDPM — 去噪扩散概率模型”论文插图,作者:Sascha Kirch

方法
DINO 框架与其他相似性学习框架(如 BYOL 或 mean teacher)以及知识蒸馏具有相同的整体结构。让我们首先看看 DINO 是如何做到这一点的,并与其他框架进行区分。

图 2:DINO 架构。 来源 + Sascha Kirch 的注释
网络和更新规则
我们从中间开始。DINO 实现了两个具有完全相同架构但权重不同的网络。这些网络分别是学生网络和教师网络。学生网络通过反向传播进行训练,而教师网络则通过其自身权重和学生网络权重的指数移动平均来更新其权重。

方程 1:教师权重的更新规则。 来源 + Sascha Kirch 的注释
骨干网络可以是 ResNet50 或 DeiT(这是为知识蒸馏而调整的 ViT)。一个基于 MLP 的投影头连接到骨干网络,以减少特征的维度,但在推理时会被移除。
很好,但用于推理的是哪个模型:学生还是教师? — 好问题,实际上论文中并没有提到这个问题的任何信息。直观上你可能会认为是学生,至少我最开始也是这样想的。但正如我们后续将看到的,教师在整个训练过程中表现优于学生。除了更好的性能之外,唯一的线索是,在代码实现中,教师检查点是用于例如 视频分割、线性探测 和 k-NN 的默认评估点。由于此参数可以更改,因此我不能给出确切的答案。
输入和输出
从输入图像 x 创建不同的视图 x1 和 x2,方法是通过裁剪和应用图像增强,如 BYOL(例如色彩抖动、高斯模糊和太阳化)。用于裁剪的技术称为 multi-crop,通过生成不同大小的多个裁剪来节省内存,同时提供更多数据。小裁剪被称为局部视图,由 96x96 像素组成,这些视图专门输入到学生网络中。较大的裁剪被称为全局视图,由 224x224 像素组成,这些视图专门输入到教师网络中。正如我们在消融部分将看到的,训练过程中使用了 2 个全局视图和 10 个局部视图。
注意:论文对于多裁剪技术有点混乱,因为提供的伪代码和上面的图 3 所示的架构都没有反映出来。伪代码甚至建议 x1 和 x2 像在 BYOL 中一样输入到学生和教师中,这在使用多裁剪时并非如此。
与相似性学习的目标是最大化嵌入的相似性不同,DINO 最小化教师和学生输出分布之间的交叉熵。如下面的方程所示,交叉熵是对每对全局和局部视图计算的,然后汇总。

方程 2:优化目标。 来源 + Sascha Kirch 的注解
模型的输出是什么? — 就像相似性学习中,学生和教师为给定的图像输出一个嵌入,而不是预测分数。就像在知识蒸馏中,输出通过 SoftMax 转换为概率分布。SoftMax 有一个温度参数,它控制结果分布的平滑或锐化。这个温度在知识蒸馏中起着关键作用,因为它可以控制从教师网络到学生网络转移一般知识和细粒度细节之间的平衡,使蒸馏过程对不同任务更有效。

图 3:温度值对 SoftMax 输出的影响。 Sascha Kirch 的插图,使用 这个 Python 笔记本 创建
我为你创建了一个笔记本,以便你可以调查温度对结果分布的影响:
[## ML_Notebooks/Softmax_Temperature.ipynb 在 main 分支 · sascha-kirch/ML_Notebooks
机器学习相关笔记的集合用于共享。— ML_Notebooks/Softmax_Temperature.ipynb 在 main 分支 ·…
避免崩溃
如前所述,学生和教师具有完全相同的架构。这种设置是不稳定的(如果没有采取对策),可能会导致崩溃解决方案,即所有特征都映射到潜在空间中的某个区域,例如最坏情况下的一个点。BYOL 通过为其中一个模型引入额外的预测头来解决这个问题,从而引入了不对称性。由于 DINO 具有对称模型,因此需要另一种技巧:中心化和锐化。两者仅应用于教师网络。中心化是一种技术,通过向教师输出添加偏置项c来防止潜在空间中的单一维度主导,即g(x) = g(x)+c。

方程 3:中心化项的更新规则。来源 + Sascha Kirch 的注释
虽然中心化具有积极效果,但它也鼓励输出崩溃为均匀分布。锐化具有相反的效果,因此应用两者平衡它们的效果并稳定训练。通过使用较小的温度来实现锐化(见图 3),教师的 SoftMax 温度比学生的低。
为了避免方程 3 中的超参数m和教师的温度崩溃是至关重要的。在附录部分的消融研究中,作者展示了m=0.9…0.999的效果最佳,并且温度值在预热期间从0.04线性增加到0.07。
DINO 是做什么的?知识蒸馏还是相似性学习?
答案是两者兼有!
虽然知识蒸馏通常是将知识从已经训练好的、更大且更准确的教师模型蒸馏到较小的学生模型中,但它也可以看作是一种相似性学习,因为它鼓励学生网络生成与教师相似的预测。在相似性学习中,两个模型通常是联合训练的,并且通常对齐它们的潜在空间预测,而不是概率分布。
由于 DINO 的作者将他们的目标表述为知识蒸馏,让我们看看与“标准”知识蒸馏相比的一些差异:
-
DINO 的教师不是事先可用的,而是与学生一起“训练”的。它甚至可以被认为是一种共同蒸馏,因为知识也从学生蒸馏到教师。
-
DINO 的教师和学生不是对相同的输入进行操作,而是对裁剪到不同尺寸的图像的不同视图进行操作。
-
DINO 在两个模型的 SoftMax 中使用不同的温度来进行锐化。
-
DINO 计算的是嵌入的温度缩放 SoftMax 上的交叉熵,而不是预测分数。
它与知识蒸馏的相似之处在哪里?:
-
DINO 由一个学生网络和一个教师网络组成,其中教师的表现优于学生,正如我们在实验中将看到的那样。
-
DINO 不是最大化相似性度量,而是最小化温度缩放的 SoftMax 输出的交叉熵损失。
[## 每当 Sascha Kirch 发布新内容时都会收到邮件 🚀
每当 Sascha Kirch 发布新内容时都会收到邮件 🚀 想要了解更多深度学习相关的内容或只是保持最新动态……
medium.com](https://medium.com/@SaschaKirch/subscribe?source=post_page-----4cb08e821b18--------------------------------)
实验
论文展示了大量的实验。他们在 ImageNet 上预训练模型,ImageNet 是一个在表征学习中常用的数据集。
对于评估,常见的技术通常要么在冻结特征上训练线性分类器,要么对模型进行微调以适应新的下游任务,在这种情况下,模型的参数会被调整。
DINO 的作者声称这些技术对超参数非常敏感,这使得比较不公平且难以重现。因此,他们建议对预训练模型的特征使用简单的最近邻聚类算法。
ImageNet 上的线性和 k-NN 分类
在这个实验中,模型在 ImageNet 上的图像分类准确性上进行了测试。测试了多种自监督预训练模型,骨干网包括 ResNet 或 ViT。分类是通过线性探测或 k-NN 聚类完成的。

表 1:在 ImageNet 上的线性和 k-NN 分类。 来源 + Sascha Kirch 的注释
我认为主要的收获是:
-
K-NN 在 ViT 特征上的表现优于 ResNet 特征。
-
在 ViT 中减少补丁大小比增加骨干网带来的改进更大,但代价是推理速度变慢。
视频实例分割
一个重要的实验是视频分割任务,因为论文讨论了 ViT 在用自监督方法训练时捕捉语义分割能力的特性。或者说这是论文所声称的 😁

表 2:视频实例分割。 来源 + Sascha Kirch 的注释
观察这些结果后,我觉得还缺少两个进一步的实验:
-
如果能看到在 DINO 框架下监督的 ResNet50 和自监督的 ResNet50 之间的对比,将会很好,这可以支持他们关于 ViT 优于 ResNet 架构的主张。
-
如果能看到相同的 ViT 骨干网在监督学习和自监督学习下的效果对比,将会非常棒,这样可以观察到对补丁大小和模型大小的影响。
不过正如我总是说的:提出问题很容易 😁 在实际项目中,作者们常常面临资源限制和项目截止日期,所以不可能涵盖每一个细节!
探索自注意力图
在这个实验中,作者调查了 ViT 的多头自注意力层中不同头部的自注意力图。他们可视化了 ViT-S/8 最后一层中选定头部的注意力图,精确来说是学习到的[CLS]令牌。

图 4:来自选定头部的注意力图。 来源 + Sascha Kirch的注释
其他实验
在其他实验中,DINO 在与监督基线的比较中有所改进。这些任务包括图像检索和复制检测。
消融实验
在他们的消融研究中,作者对 ViT-S 模型进行了实验。
补丁大小的重要性
记住,视觉变换器输入的是一个补丁化的输入图像,将每个补丁转化为令牌,然后应用具有自注意力机制的变换器。这是 ViT 作者的一项技巧,用于减少性能权衡的计算需求,使变换器适用于图像数据。
DINO 声称,较小的补丁大小提高了性能,同时降低了吞吐量(每秒可以处理的图像数量),这正是 ViT 所声称的。

图 5:补丁大小对准确性和吞吐量的影响。 来源 + Sascha Kirch的注释
直观地说,这并不令人惊讶,因为你增加了输入分辨率,结果是需要处理更多的令牌,因此你得到一个更细粒度的注意力图。
不同的教师更新规则
DINO 中的教师通过计算从更新后的学生和当前教师的指数移动平均来更新。这就是他们所称的“动量编码器”方法。
使用动量编码器并绘制教师和学生在训练过程中的准确性,教师在整个过程中表现更好。由此我们可以假设:
-
教师可以为学生提供强有力的学习信号。
-
改进的学生由于 EMA 更新规则(共同蒸馏)使教师得到提升。
-
可以使用教师作为最终模型,该模型具有更好的性能,但与学生具有相同的架构,因此计算需求没有变化。

图 6:教师性能。 来源 + Sascha Kirch的注释
他们还实验了另外 3 种更新规则:将权重从学生复制到教师,使用优化器前一个迭代的学生权重,和使用前一个时代的学生权重。
多裁剪与时间和 GPU 内存
如前所述,DINO 输入相同图像的多个裁剪视图,并将全局视图输入到教师模型中,将局部视图输入到学生模型中。在这项消融实验中,作者试验了不同数量的局部视图,并报告了对性能、训练时间和每 GPU 峰值内存的影响。

表 3:多裁剪与时间和 GPU 内存。来源 + Sascha Kirch的注释
避免崩溃
在这项消融实验中,作者评估了其稳定措施在避免崩溃解决方案中的作用:中心化和锐化。
为此,他们将交叉熵分解为熵项和 Kullback-Leibler(KL)散度项。KL 散度是两个概率分布差异的度量。如果 KL 为 0,则认为两个分布相等。
其直观的理解是:如果教师和学生的输出分布的 KL 散度在整个训练过程中保持不变,那么学生的权重更新就没有学习信号。

图 7:崩溃解决方案分析。来源 + Sascha Kirch的注释
批量大小的影响
一个有趣的特性是,DINO 可以用较小的批量大小进行训练,而不会大幅下降性能。这实际上是 BYOL 的一个动机,DINO 基于此论文,减少了对批量大小的依赖,相比对比自监督学习方法。

表 4:批量大小与准确率。来源 + Sascha Kirch的注释
类似 CLIP 和 GLIP 的对比方法提供了大量的负样本以避免崩溃解决方案。每次优化器更新步骤(因此每批次)的负样本越多,效果越好。
结论
总结来说,DINO 是一个知识蒸馏框架。它是一个视觉基础模型,利用了 ViTs 的有趣特性,并且是今天表现最好的基础模型之一 DINOv2 的前身。DINO 的框架由学生模型和教师模型组成,作用于相同图像的不同视图,并采取额外措施来处理相似性学习方法的固有不稳定性。实验表明,DINO 在各种任务上优于其他自监督预训练模型。
进一步阅读与资源
论文
与此同时,DINO 的改进版本已经发布:
论文解读
你可能还会喜欢我其他的论文解读,涵盖了我们在本文中讨论的概念:
论文总结—从自然语言监督中学习可转移的视觉模型
towardsdatascience.com ## GLIP:引入语言-图像预训练到目标检测
论文总结:基于语境的语言-图像预训练
towardsdatascience.com ## BYOL -对比自我监督学习的替代方案
论文分析—自我监督学习的新方法:Bootstrap Your Own Latent
towardsdatascience.com ## Segment Anything — 可提示的任意对象分割
论文讲解—Segment Anything
towardsdatascience.com
方向改善图学习
原文:
towardsdatascience.com/direction-improves-graph-learning-170e797e94fe
有向图上的图神经网络
研究在异质图上进行消息传递时合理使用方向可以带来非常显著的提升。
·发布于 Towards Data Science ·10 分钟阅读·2023 年 6 月 8 日
--
图神经网络(GNNs)在建模关系数据方面非常有效。然而,当前的 GNN 模型通常假设输入图是无向的,忽略了许多实际图(如社交网络、交通网络、交易网络和引用网络)固有的方向性。在这篇博文中,我们探讨了在异质图的背景下边的方向性影响,并概述了 Dir-GNN,一种针对有向图的全新消息传递方案,允许单独聚合进入和离开边。尽管其简单性,该方案在多个实际异质有向图上显著提高了性能。

基于 Shutterstock。
本文由 埃曼纽尔·罗西 共同撰写,基于论文 E. Rossi et al., “边的方向性改善异质图上的学习” (2023) arXiv:2305.10498,与 贝特朗·夏尔潘捷、 弗朗西斯科·迪·乔瓦尼、 法布里齐奥·弗拉斯卡 和 斯特凡·古恩曼 [1] 合作完成。论文的代码可以在 这里找到。
许多有趣的实际图,例如在建模社交、交通、金融交易或学术引用网络时遇到的图,都是有向的。边的方向通常传达了关键的见解,否则如果仅考虑图的连接模式,这些见解将会丧失。
相反,大多数在各种图机器学习应用中取得显著进展的图神经网络(GNNs)假设输入图是无向的。多年来,使输入图成为无向图已变得非常普遍,以至于流行的 GNN 库之一 PyTorch-Geometric 在加载数据集时包含了一个通用工具函数,该函数会自动将图转换为无向图[2]。
对无向图的这种倾向源于 GNNs 的两个“原罪”。首先,无向图具有对称的拉普拉斯算子和正交特征向量,提供了傅里叶变换的自然推广,而早期的谱 GNN 依赖于此以正常运作。其次,早期用于基准测试 GNNs 的数据集主要是同质性图[3],如 Cora 和 Pubmed[4]。在这些数据集中,通过将定向图转换为无向图来忽略方向似乎是有利的,早期证据有助于巩固“无向”范式。

在同质性图(左)中,方向大多无用,这一观察导致了大多数当前的 GNNs 忽视了这一信息。相反,在异质性设置中(右),如果使用得当,方向性可以带来大幅收益(10%到 15%),正如我们在 Dir-GNN 框架中提出的那样。
我们在最近的论文[5]中挑战了这一现状,表明方向性可以在异质性设置中带来广泛的收益。
在定向图中测量同质性
图的同质性通常被测量为与节点本身具有相同标签的邻居的比例,平均遍及所有节点(节点同质性)。对于定向图,我们提出了加权节点同质性:
h(S) = 1/n Σᵤ ( Σᵥ sᵤᵥ * I[yᵤ = yᵥ] ) / Σᵥ sᵤᵥ
其中I表示指示函数,n是节点数量,S是一般邻接矩阵,可以选择𝐀或𝐀ᵀ,或者更高阶矩阵,例如𝐀𝐀ᵀ或𝐀²(对于定向图),或对称矩阵𝐀ᵤ= (𝐀+ 𝐀ᵀ) / 2 及其高阶对应矩阵𝐀ᵤ²(如果图被视为无向图)。
即使当 1-hop 邻居存在异质性[6]时,情况也可能在转到更远的节点时发生变化。与无向图相比,定向图中有四种不同的 2-hops,分别由矩阵𝐀²、(𝐀ᵀ)²、𝐀𝐀ᵀ和𝐀ᵀ𝐀表示,这些矩阵可以体现出不同程度的(加权)同质性。
由于 GNNs 通过多跳聚合进行操作,它们可以利用图中任何 2-hop(甚至更远的跳数)的同质性。为了获得一个全面的度量来捕捉 GNN 原则上可以利用的最大同质性,我们引入了有效同质性的概念,定义为图中任何跳数的最大加权节点同质性。
从经验上看,当将图转为无向图时,有向同质数据集的有效同质性保持不变。相反,在异质图中,这种转换平均减少了约 30%的有效同质性。

我们比较了多种同质和异质数据集的有向和无向扩散矩阵的加权同质性。对于异质数据集,有向图的有效同质性(h⁽ᵉᶠᶠ⁾)比无向图的(h⁽ᵉᶠᶠ⁾)大得多,表明有效利用方向性可能带来潜在的收益。

在合成实验中,我们再次观察到,有向的随机块模型图的有效同质性始终高于其无向对应物。有趣的是,对于较少同质的图,这一差距会扩大。
一个玩具示例
特别地,我们观察到𝐀𝐀ᵀ和𝐀ᵀ𝐀在异质图中始终出现为“最同质的矩阵”。
为了提供一个直观的理解,想象我们正在尝试预测一篇特定学术论文的出版年份,例如 Kipf & Welling 2016 年 GCN 论文,给定有向引用网络和其他论文的出版年份。考虑两种不同的 2 跳关系:一种是查看我们关注的论文 v 引用的论文的引用(由矩阵 𝐀² 的 v 行表示),另一种是查看引用与我们论文相同来源的论文(由(𝐀𝐀ᵀ)ᵥ表示)。
在第一种情况下(𝐀²),我们从 GCN 论文开始,并跟随其引用两次。我们最终找到了一篇 1998 年由 Frasconi et al. 发表的论文。这篇较旧的论文并没有提供很多关于我们的 GCN 论文发布时间的有用信息,因为它时间跨度过长。

有向引用网络的玩具示例。
在第二种情况下(𝐀𝐀ᵀ),我们从 GCN 论文开始,跟随一个引用,然后返回到引用相同来源的论文,例如 2017 年的 GAT 论文。这篇论文与我们的 GCN 论文出版年份更接近,因此提供了更好的线索。更一般地,分享更多引用的节点,如我们第二个例子中的节点,在𝐀𝐀ᵀ中的分数更高,因此对我们的最终预测贡献更大。
现在,考虑一个无向 2 跳关系(𝐀ᵤ²),这只是四种可能的 2 跳矩阵的平均值。这包括我们的第一种类型(如 Frasconi et al.),这并不是非常有用。因此,高度有用的𝐀𝐀ᵀ矩阵被较少信息的矩阵(如𝐀²)稀释,导致一个较少同质的运算符,从而总体上导致一个较不可靠的预测器。
虽然我们在示例中使用了引用网络,但这种直觉具有更广泛的适用性。在社交网络中,例如,影响者的特征更可能与那些有很多共同关注者的用户类似,由 𝐀ᵀ𝐀 表示。类似地,在交易网络中,两个账户向同一组账户汇款(由 𝐀𝐀ᵀ 捕获),很可能表现出类似的行为。
Dir-GNN:有向图神经网络
为了有效利用方向性,我们提出了 有向图神经网络(Dir-GNN)框架,它通过对节点的入邻居和出邻居进行独立聚合,将 MPNNs 扩展到有向图:
mᵤ⁽ᵏ⁾ᵢₙ = AGGᵢₙ({{xᵥ⁽ᵏ⁻¹⁾, xᵤ⁽ᵏ⁻¹⁾) : (u,v) ∈ E }})
mᵤ⁽ᵏ⁾ₒᵤₜ = AGGₒᵤₜ({{xᵥ⁽ᵏ⁻¹⁾, xᵤ⁽ᵏ⁻¹⁾) : (v,u) ∈ E }})
xᵤ⁽ᵏ⁾ = COM(xᵤ⁽ᵏ⁻¹⁾, mᵤ⁽ᵏ⁾ᵢₙ, mᵤ⁽ᵏ⁾ₒᵤₜ)
其中,聚合映射 AGGᵢₙ 和 AGGₒᵤₜ,以及组合映射 COM 是可学习的(通常是一个小的神经网络)。重要的是,AGGᵢₙ 和 AGGₒᵤₜ 可以拥有独立的参数集,以允许对入边和出边进行不同的聚合 [7]。
有趣的是,这种程序模式类似于经典 Weisefiler-Lehman 图同构测试(1-WL)对有向图的自然扩展 [8]。这一联系非常重要:在区分能力方面,我们证明了 Dir-GNN 严格比标准 MPNNs 更强大,后者要么将图转换为无向图,要么仅沿边的方向传播消息。
我们的框架也很灵活:定义特定架构(如 GCN、GraphSAGE 或 GAT)的有向对应物很容易。例如,我们可以定义 Dir-GCN 为:
𝐗⁽ᵏ⁾ = σ(𝐒ₒᵤₜ𝐗⁽ᵏ⁻¹⁾𝐖ₒᵤₜ⁽ᵏ⁾ + (𝐒ₒᵤₜ)ᵀ𝐗⁽ᵏ⁻¹⁾𝐖ᵢₙ⁽ᵏ⁾)
其中 𝐒ₒᵤₜ= Dₒᵤₜ⁻¹ᐟ² 𝐀 Dᵢₙ⁻¹ᐟ²,Dᵢₙ 和 Dₒᵤₜ 分别表示对角线的入度和出度矩阵。
我们还展示了 Dir-GNN 在多层迭代应用时,能导致更具同质性的聚合。与其他模型不同,Dir-GNN 可以访问四个 2-hop 矩阵 𝐀²、(𝐀ᵀ)²、𝐀𝐀ᵀ 和 𝐀ᵀ𝐀,并学会对它们进行不同的加权。相比之下,操作在无向图上的模型仅能访问 𝐀ᵤ²,而仅沿入边或出边传播信息的模型分别限于 (𝐀ᵀ)² 和 𝐀²。
由于 Dir-GNN 对两个方向的独立聚合,因此它是唯一一个在 𝐀𝐀ᵀ 和 𝐀ᵀ𝐀 上操作的模型,我们已经证明这两个矩阵是最具同质性的,因此最可靠的预测器。
实验结果
我们首先在一个需要方向信息的合成任务上比较了 GraphSAGE 及其有向扩展(Dir-SAGE)。结果确认,只有 Dir-SAGE(in+out)在访问到入边和出边的情况下,能够几乎完美地解决该任务。作用于无向图版本的模型表现得与随机情况相当,而仅对入边或出边的模型表现相似,准确率约为 75%。

在检查 GraphSAGE 及其 Dir-扩展在一个需要方向性信息的合成任务上的表现时,只有利用双向信息的 Dir-SAGE (in+out)才能解决该任务。
我们进一步通过消融研究验证了我们的方法,将 GCN、GraphSAGE 和 GAT 基础模型与它们的 Dir-扩展进行比较。在异质数据集上,使用方向性在所有三个基础 GNN 模型中带来了异常大的准确率提升(10%到 20%绝对提升)。此外,Dir-GNN 击败了专门为异质图设计的最先进模型。
这些结果表明,当存在时,使用边的方向可以显著提高异质图上的学习效果。相比之下,忽略方向性是非常有害的,即使是复杂的架构也无法弥补信息的丧失。

在异质图上,通过明智地使用方向性取得了新的最先进结果。
另一方面,在同质数据集上使用方向性则表现不变(甚至稍有负面影响)。这与我们的发现一致,即在我们的框架中使用方向性通常会增加异质数据集的有效同质性,而对同质数据集几乎没有影响。
总之,我们的论文展示了在 GNN 中利用方向性的好处,特别是在异质图的情况下。我们希望这些发现能引发范式转变,将方向性提升为 GNN 中的一等公民。简而言之,在使图无向之前三思而后行!
[1] 本帖标题“方向提升图学习”是对 J. Gasteiger、S. Weissenberger 和 S. Günnemann 的先前工作Diffusion improves graph learning(2019 年)的故意戏仿,该工作展示了基于扩散的图重连方案(DIGL)在同质环境中提高了 GNN 的性能。在这里,我们关注的是异质情况。
[2] 这个 Pytorch-Geometric 例程 用于加载存储在 npz 格式中的数据集。它将一些定向数据集,如 Cora-ML 和 Citeseer-Full,自动转换为非定向版本,并且没有选项获取定向版本。
[3] 同质性 指的是节点具有类似属性(通常是标签,有时是特征)趋向于连接在一起的假设。在同质图中,一个节点的邻域看起来就像是一个节点本身,通常允许通过对邻居的简单聚合(例如,平均)来预测节点的属性。违反这一假设的图称为异质性图。
[4] Cora 数据集由 Andrew McCallum 于 1990 年代末期引入,它对 GNNs 的意义相当于 MNIST Digits 数据集对 CNNs 的意义。
[5] E. Rossi 等人,“边的方向性改善了在异质图上的学习”(2023)arXiv:2305.10498。
[6] 异质性并不一定是本质上不好的。例如,考虑以下具有三类(蓝色、橙色、绿色)的玩具定向图:

查看不同邻接矩阵的兼容性矩阵(兼容性矩阵中的位置ij 捕获了从标签为 i 的节点到标签为 j 的节点的边的比例,按给定邻接矩阵加权)。同质邻接矩阵在其兼容性矩阵的对角线上的质量更高,因为它包含了标签相同的节点之间的边。而在我们的示例中,定向(𝐀)和非定向(𝐀ᵤ)的一跳都是极度异质的,而定向的两跳(𝐀𝐀ᵀ 和 𝐀ᵀ𝐀)比非定向的两跳(𝐀ᵤ²)更具同质性。
[7] 重要的是要注意,我们不是第一个处理定向图并提出单独聚合入邻居和出邻居的人。然而,我们的贡献在于提供了对定向图的更全面的处理,包括一个通用框架(Dir-GNN)、关于方向性好处的全面实证证据,特别是在异质性背景下,以及分析定向图模型表达能力的起点。有关相关先前工作的更详细概述,请参阅我们论文中的“相关工作”部分 [5]。
[8] 尽管已经提出了多个关于WL 测试在有向图上的扩展,但 M. Grohe、K. Kersting、M. Mladenov 和 P. Schweitzer 讨论的变体,色彩细化及其应用中,“An Introduction to Lifted Probabilistic Inference”(2021),MIT Press,将入邻居和出邻居分开处理。
我们感谢 Christopher Morris 和 Chaitanya K. Joshi 的深刻讨论,并指出了相关工作。有关图上深度学习的更多文章,请参见 Michael 的 其他文章 在 Towards Data Science 中, 订阅 他的文章和 YouTube 频道,获取 Medium 会员资格,或在 Twitter上关注他。
Dirichlet 分布:基础直观理解及 Python 实现
关于 Dirichlet 分布你需要知道的一切
·发布于 Towards Data Science ·27 分钟阅读·2023 年 8 月 1 日
--

图片来源: pixabay.com/vectors/cubes-dice-platonic-solids-numbers-160400/
Dirichlet 分布是贝塔分布的一种推广。在贝叶斯统计中,它通常用作多项式分布的共轭先验,因此可以用来建模概率随机向量的不确定性。它具有广泛的应用,包括贝叶斯分析、文本挖掘、统计遗传学和非参数推断。本文对 Dirichlet 分布进行了直观介绍,并展示了它与多项式分布的联系。此外,还展示了如何在 Python 中建模和可视化 Dirichlet 分布。
定义
假设连续随机变量 X₁, X₂, …Xₖ (k≥2) 形成随机向量 X,定义为:

我们还定义了向量 α 如下:

其中

现在,如果随机向量 X 具有参数 α 的 Dirichlet 分布,则它具有以下联合 PDF:

函数 B(α) 称为 多变量 贝塔函数,其定义为

其中 Г(x) 是伽马函数。如果随机向量 X 具有参数 α 的 Dirichlet 分布,则记作 X ~ Dir(α)。多变量贝塔函数包含在联合概率密度函数(PDF)中用于归一化。联合 PDF 应该在其定义域上积分为 1:

因此,我们有:

基于方程 1,随机变量X₁、X₂、…Xₖ的取值应满足以下条件,以使fₓ(x)>0:

这些条件定义了 Dirichlet 分布的支持。X的支持及其分布的支持是所有x的集合(X可以取的值),其中fₓ(x)>0。如果X有k个元素,具有 Dirichlet 分布的X的支持是一个k-1 维的单纯形。单纯形是由于方程 3 的约束而形成的有界线性流形。单纯形是三角形概念的高维推广。因此,k-1 维单纯形可以被视为一个位于k维空间中的k-1 维三角形。
例如,如果k=2,那么X的支持是图 1(左)中显示的 1 维单纯形。它是一条直线,触及每个坐标轴,距离原点 1 个单位。对于这条线上的每一点,我们有:

对于k=3,那么X的支持是图 1(右)中显示的 2 维单纯形。现在它是一个触及每个坐标轴的三角形,距离原点 1 个单位。

图 1(作者提供)
对于这个三角形表面上的每一个点,我们有:

让随机向量

具有参数的 Dirichlet 分布:

让

然后,可以证明X的均值如下:

也可以证明:

直觉
如前所述,Dirichlet 分布通常作为多项分布的共轭先验。因此,为了理解其背后的直觉,我们首先需要回顾多项分布。假设离散随机向量X定义为:

让向量p为:

那么X被称为具有参数n和p的多项分布,如果它具有以下联合 PMF:

多项分布可以用于建模一个k面骰子。假设我们有一个k面骰子,并将其掷n次。让pᵢ表示获得第i面的概率,并让随机变量Xᵢ表示第i面观察到的总次数(i=1…k)。那么随机向量

具有参数n的多项分布

这一点在图 2 中展示。

图 2(作者提供的图片)
现在假设我们不知道向量 p 中的 pᵢ 值。因此,我们不知道每一面 k-面骰子的概率,我们想通过观察 n 次掷骰子的结果来推断它。p 的元素表示一些互斥事件的概率,因此我们应该有:

p 的值可以使用 贝叶斯方法 推断。在这里,我们假设未知的概率向量 p 由连续随机向量 P 表示。P 的概率分布称为 先验分布。先验分布表示对估计参数 P 的先验知识或假设。在掷骰子之后,我们可以分析观察到的数据,并使用这些数据更新我们对 P 的信念。因此,我们得到一个新的 P 分布,这称为 后验分布。后验分布是通过用观察数据更新先验概率分布得到的。
请记住,随机向量X中的随机变量 Xᵢ 代表观察到的第 i 面的总次数。如果我们知道 p 的值,我们可以使用以下条件概率计算在 n 次掷骰子后观察到 X₁=m₁, X₂=m₂, … Xₖ=mₖ 的概率:

其中:


这个条件概率给出了在 n 次掷骰子后观察到每一面骰子特定次数的概率,假设我们知道 P 的真实值。如前所述,P 的概率分布是我们的先验分布。我们用 f_P(p) 来表示这个分布的联合概率密度函数。现在,我们可以使用贝叶斯定理将先验和后验联合概率密度函数连接起来:

这里 f_P|X (p|X=m) 是后验分布的联合概率密度函数。这个分布在观察到 X 后更新我们对 P 的信念。我们也称 P(X=m|p) 为似然函数,它可以写成一个已知 p 值的多项式分布的概率质量函数(方程 4):

贝叶斯定理的分母是 X=m 的概率,称为 X 的边际概率质量函数:

请注意,这与 p 的真实值无关。现在我们假设先验分布是具有参数 α₁ 的 Dirichlet 分布。

其中

记住,具有 Dirichlet 分布的随机变量应遵循方程 3 中的条件,这些条件与方程 5 的条件完全相同。实际上,方程 3 中的条件允许我们使用 Dirichlet 分布来表示互斥事件的概率的随机变量。
现在我们可以使用贝叶斯规则(方程 6)来写:

这里 c 是一个不依赖于 pᵢ 值的常数。后验联合 PDF 应该被归一化,因此我们有以下条件:

通过将方程 7 和 8 与方程 1 和 2 进行比较,我们得出结论,后验分布是一个参数为的 Dirichlet 分布

和 c 仅仅是其归一化因子,我们得到:

最后,我们可以写:

所以,如果我们假设先验分布是 Dirichlet 分布,那么在观察到 X=m 之后的后验分布也是 Dirichlet 分布。我们只需将每个侧面观察到的数量 (mᵢ) 添加到先验分布中的相应参数 (αᵢ) 中,就能得到后验分布的参数。
在贝叶斯概率理论中,如果后验分布属于与先验分布相同的家族,那么先验和后验被称为 共轭分布。因此,我们得出结论,Dirichlet 分布是多项分布的共轭先验(图 3)。

图 3(作者提供的图片)
Dirichlet 分布的一个特殊情况是当

然后我们得到:

这意味着

与其 k-1 维单纯形上的均匀分布是一样的,因为联合 PDF 在单纯形上具有相同的值。
在 Python 中建模和可视化
我们可以使用 scipy 库在 Python 中对 Dirichlet 分布进行建模。在 scipy 中,Dirichlet 分布可以通过对象 dirichlet 创建。该对象接受参数 alpha,该参数对应于方程 1 中的 α。我们也可以将 alpha 传递给此对象的方法。方法 pdf() 还接受参数 x,该参数对应于方程 1 中的 x,并返回 x 处分布的联合 PDF。我们还可以使用方法 mean() 和 var() 计算分布的均值和方差。例如,设:

现在我们想计算 X 的均值及其在 [0.5, 0.3, 0.2]ᵀ 的联合 PDF,使用以下代码片段:
from scipy.stats import dirichlet
dist = dirichlet([5, 5, 5])
print("PDF at [5,5,5]: ",dist.pdf([0.5, 0.3, 0.2]))
print("Mean of disitrubtion: ", dist.mean())
PDF at [5,5,5]: 5.1081030000000025
Mean of disitrubtion: [0.33333333 0.33333333 0.33333333]
如果 x 的值在单纯形之外,pdf() 会抛出错误:
# This results in an error
dist.pdf([0.5, 0.3, 0.3])

我们可以可视化 k=2 和 3 时的 Dirichlet 联合 PDF(k 是 X 的元素个数)。如前所述,当我们在 X 中有 3 个随机变量(具有 Dirichlet 分布)时,单纯形是一个二维三角形(图 1)。我们可以计算这个单纯形表面上联合 PDF 的轮廓,并用 重心坐标 在二维图中绘制(图 4)。

图 4(作者提供的图像)
重心坐标是一个点在仿射空间中相对于单纯形的坐标。它们可以提供一个点相对于直线、三角形或四面体的位置,而不是全局笛卡尔坐标。在 k 维笛卡尔坐标系中,一个点的坐标可以表示为 k-1 维单纯形的边的归一化加权平均。这些权重给出了该点相对于单纯形的重心坐标。考虑图 5 中显示的二维空间。

图 5(作者提供的图像)
一维单纯形是端点 [0,1] 和 [0,1] 之间的线段。在这个单纯形上的任意点 p 的坐标可以表示为端点坐标的归一化加权平均:

其中

这里 λ₁ 是 p 到端点 [0,1] 的距离除以单纯形的长度 (L)。类似地,λ₂ 是 p 到端点 [1,0] 的距离除以 L。权重 λ₁ 和 λ₂ 是 p 相对于该单纯形的重心坐标,并且由于端点距离原点只有一个单位,它们与笛卡尔坐标具有相同的值。
接下来,考虑图 6 中显示的二维单纯形。这个单纯形是由端点 [1,0,0]、[0,1,0] 和 [0,0,1] 组成的三角形。该单纯形上点 p 的坐标等于这些端点坐标的归一化加权平均:

其中


图 6(作者提供的图像)
在这个三角形中,每个节点代表一个坐标轴 x₁、x₂ 或 x₃。假设我们要计算 x₁ 的值。设每条边的长度为 L(这是一个等边三角形)。为了得到 x₁ 的值,我们绘制一条经过 p 并且与不经过 x₁ 所代表的节点的边(这里是 x₂x₃)平行的直线。这条直线将其余的每一边(x₁x₂ 和 x₁x₃)分成两个线段。在这些边上,不包含节点 x₁ 的线段长度是 λ₁L(见图 6)。我们可以类似地计算 λ₂ 和 λ₃ 的值。
现在,我们创建一个 Python 函数来绘制二维单纯形上 Dirichlet 分布的联合 PDF 的等高线。清单 1 导入了我们后续需要的所有库,并在二维图上定义了这个三角形单纯形的边。这些边存储在列表edges中。请注意,这个二维单纯形现在绘制在二维屏幕上,因此所有的边都是二维的。然而,点的重心坐标仍然是这些边的笛卡尔坐标的加权平均值:

这里的H是三角形的高度(图 7)。
我们使用matplotlib.tri库来创建一个三角网格。数组normal_vecs保存了这个三角形每条边的法向量(每条边的法向量都垂直于该边)。
# Listing 1
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.tri as tri
from scipy.stats import dirichlet, multinomial, beta
from math import pi
from mpl_toolkits.axes_grid1 import make_axes_locatable
import matplotlib.gridspec as gridspec
%matplotlib inline
H = np.tan(pi/3)*0.5
edges = np.array([[0, 0], [1, 0], [0.5, H]])
shifted_edges = np.roll(edges, 1, axis=0)
triangle = tri.Triangulation(edges[:, 0], edges[:, 1])
# For each edge of the triangle, the pair of other edges
edge_pairs = [edges[np.roll(range(3), -i)[1:]] for i in range(3)]
# The normal vectors for each side of the triangle
normal_vecs = np.array([[pair[0,1] - pair[1,1],
pair[1,0] - pair[0,0]] for pair in edge_pairs])
在清单 2 中,函数cart_to_bc()将点的二维笛卡尔坐标转换为相对于edges中定义的二维三角形的重心坐标。
# Listing 2
def cart_to_bc(coords):
'''Converts 2D Cartesian coordinates to barycentric'''
bc_coords = np.sum((np.tile(coords, (3, 1))-shifted_edges)*normal_vecs,
axis=1) / np.sum((edges-shifted_edges)*normal_vecs, axis=1)
return np.clip(bc_coords, 1.e-10, 1.0 - 1.e-10)
def bc_to_cart(coords):
'''Converts barycentric coordinates to 2D Cartesian'''
return (edges * coords.reshape(-1, 1)).sum(axis=0)
图 7 展示了如何进行这些计算以计算λ₃(作为示例)。如图所示,三角形的一条边(x₁)位于二维笛卡尔坐标系统的原点。我们可以用向量x₁p来表示这个三角形上的点p。从几何学中,我们知道

其中n是边x₁x₂的法向量。因此,如果我们知道点p的笛卡尔坐标、三角形的边以及每条边的法向量,我们就可以轻松计算出点p的重心坐标。

图 7(作者提供的图片)
需要注意的是,这个函数并不总是返回准确的重心坐标。如果重心坐标超出了区间 [1e-10 -10, 1–1e-10],则会使用numpy中的clip()函数将其裁剪到区间边界。原因将在后文中解释。
我们还有函数bc_to_cart(),它将这个三角形的重心坐标转换为笛卡尔坐标。点p的笛卡尔坐标等于三角形边的笛卡尔坐标的加权平均值,而重心坐标只是这些权重:

最后,列表 3 定义了函数plot_contours(),该函数绘制 Dirichlet 分布在这个三角形上的联合 PDF 等高线。此函数在笛卡尔 2D 空间上创建一个三角网格。接下来,计算网格上每个点的重心坐标。然后,它使用重心坐标计算该点的联合 PDF。在计算完三角形上所有点的联合 PDF 后,绘制等高线。请注意,三角网格上的某些点可能稍微超出简单边界。这意味着该点的 x₁+x₂+x₃ 可能稍微小于零或大于 1。将这样的点传递给 dirichlet 对象的 pdf() 方法会引发错误。因此,我们在 cart_to_bc() 中裁剪重心坐标以避免此错误。
# Listing 3
def plot_contours(dist, nlevels=200, subdiv=8, ax=None):
refiner = tri.UniformTriRefiner(triangle)
mesh = refiner.refine_triangulation(subdiv=subdiv)
pdf_vals = [dist.pdf(cart_to_bc(coords)) for coords in zip(mesh.x, mesh.y)]
if ax:
contours = ax.tricontourf(mesh, pdf_vals, nlevels, cmap='jet')
ax.set_aspect('equal')
ax.set_xlim(0, 1)
ax.set_ylim(0, H)
ax.set_axis_off()
else:
contours = plt.tricontourf(mesh, pdf_vals, nlevels, cmap='jet')
plt.axis('equal')
plt.xlim(0, 1)
plt.ylim(0, H)
plt.axis('off')
return contours
让我们尝试 plot_contours()。我们首先绘制等高线

如前所述,由于联合 PDF 在单纯形上具有相同的值,因此它与其 2D 单纯形上的均匀分布相同。列表 4 绘制了联合 PDF 的等高线,结果图如图 8 所示。
# Listing 4
plt.figure(figsize=(10, 10))
contours = plot_contours(dirichlet([1, 1, 1]))
v = np.linspace(0, 3, 2, endpoint=True)
plt.colorbar(contours, ticks=[1,2,3], fraction=0.04, pad=0.1)
plt.text(0-0.02, -0.05, "$p_1$", fontsize=22)
plt.text(1-0.02, -0.05, "$p_2$", fontsize=22)
plt.text(0.5-0.02, H+0.03, "$p_3$", fontsize=22)
plt.title("Dir([1,1,1])", fontsize=22)
plt.show()

图 8
如您所见,联合 PDF 在整个单纯形上具有相同的值。接下来,我们绘制等高线

作为列表 5 中的第二个示例。结果如图 9 所示。
# Listing 5
plt.figure(figsize=(10, 10))
contours = plot_contours(dirichlet([5, 5, 5]))
plt.colorbar(contours, fraction=0.04, pad=0.1)
plt.text(0-0.02, -0.05, "$p_1$", fontsize=22)
plt.text(1-0.02, -0.05, "$p_2$", fontsize=22)
plt.text(0.5-0.02, H+0.03, "$p_3$", fontsize=22)
plt.title("Dir([5,5,5])", fontsize=22)
plt.show()

图 9
影响 α 对联合 PDF 的影响
我们还可以创建联合 PDF 表面的 3D 图。这里我们假设 2D 单纯形位于 XY 平面,Z 轴给出 PDF 的值。列表 6 中的函数plot_surface()生成这样的图。
# Listing 6
def plot_surface(dist, ax, nlevels=200, subdiv=8, log_plot=False, **args):
refiner = tri.UniformTriRefiner(triangle)
mesh = refiner.refine_triangulation(subdiv=subdiv)
pdf_vals = [dist.pdf(cart_to_bc(coords)) for coords in zip(mesh.x, mesh.y)]
pdf_vals = np.array(pdf_vals, dtype='float64')
if log_plot:
pdf_vals = np.log(pdf_vals)
ax.plot_trisurf(mesh.x, mesh.y, pdf_vals, linewidth=1, **args)
列表 7 使用此函数绘制了具有不同参数的 Dirichlet 分布的联合 PDF。图形如图 10 所示。
# Listing 7
fig = plt.figure(figsize=(15, 10))
ax1 = fig.add_subplot(231, projection='3d')
ax2 = fig.add_subplot(232, projection='3d')
ax3 = fig.add_subplot(233, projection='3d')
ax4 = fig.add_subplot(234, projection='3d')
ax5 = fig.add_subplot(235, projection='3d')
ax6 = fig.add_subplot(236, projection='3d')
ax = [ax1, ax2, ax3, ax4, ax5, ax6]
params = [[1,1,1], [1,7,1], [0.65,7,1], [5,5,5], [30,30,30], [5, 5, 30]]
for i in range(6):
plot_surface(dirichlet(params[i]), ax[i],
antialiased=False, color='yellow')
ax[i].view_init(35, -135)
ax[i].set_title("Dir({})".format(params[i]), fontsize=16)
ax[i].zaxis.set_rotate_label(False)
ax[i].set_zlabel("$f_\mathregular{P}(\mathregular{p})$", fontsize=16,
weight="bold", style="italic", labelpad=5, rotation=90)
ax[i].set_xlim([-0.15, 1.1])
ax[i].set_ylim([-0.15, 1.1])
if i>2:
ax[i].set_zlim([0, 65])
ax[i].xaxis.set_ticklabels([])
ax[i].yaxis.set_ticklabels([])
ax[i].set_xticks([])
ax[i].set_yticks([])
if i==0:
ax[i].text(-0.15, -0.07, 2, "$p_1$", fontsize=14)
ax[i].text(1.07, 0.03, 2, "$p_2$", fontsize=14)
ax[i].text(0.5, H+0.15, 2, "$p_3$", fontsize=14)
else:
ax[i].text(-0.15, -0.07, 0, "$p_1$", fontsize=14)
ax[i].text(1.07, 0.03, 0, "$p_2$", fontsize=14)
ax[i].text(0.5, H+0.15, 0, "$p_3$", fontsize=14)
plt.show()

图 10
这些图可以帮助您理解 α 对联合 PDF 形状的影响。具有 Dirichlet 分布的随机变量 p₁、p₂ 和 p₃ 可以表示 3 个相互排斥事件的概率。因此,单纯形的每条边表示这些事件中的一个,相应的 αᵢ 就像是该事件发生概率的权重。
如前所述,α=[1 1 1]ᵀ 意味着我们在单纯形上有均匀分布。这里,PDF 的值在单纯形上处处为 2,因此联合 PDF 具有平坦的表面。当 αᵢ 相对于其他元素增加时,这意味着第 i 个事件发生的机会更高,因为与其他事件相比,它被观察得更多(这里我们可以假设我们从 Dir([1 1 1]ᵀ) 作为先验分布开始)。一个例子是图 10 中 Dir([1 7 1]ᵀ) 的图形。现在,表面在单纯形的边缘附近升高,表示该事件。
当总和α₁+α₂+α₃增加时,意味着观察的总数量增加了。这将减少我们对P的分布的不确定性,并使 Dirichlet 分布的联合 PDF 看起来更尖锐。正如你在图 10 中看到的,Dir([30 30 30]ᵀ)相比 Dir([5 5 5]ᵀ)要尖锐得多。然而,两者在边缘上看起来都是对称的。因为所有事件被观察的次数相同。当某个αᵢ相对于其他值变大时,联合 PDF 的峰值会向表示该事件的边缘移动。这在 Dir([5 5 30]ᵀ)中得到了体现。这里第三个事件的权重(α₃)较大,意味着第三个事件被观察得更多,因此发生的概率更高。
请注意,所有的α元素应大于零,因此我们不能给事件分配零权重。然而,如果我们设置αᵢ<1,则相应事件的权重会显著下降。这在图 10 中的 Dir([0.65 7 1]ᵀ)的图示中得到了体现。如果你将其与 Dir([1 7 1]ᵀ)的图示进行比较,你会发现为了得到非零 PDF,p₁的重心坐标应非常小。这几乎像在p₂和p₃上有一个 1 维的简单形体。
列表 8 绘制了 Dirichlet 分布的联合 PDF 的对数尺度图(以更好地展示联合 PDF 表面的变化)。结果如图 11 所示。
# Listing 8
fig = plt.figure(figsize=(15, 10))
ax1 = fig.add_subplot(121, projection='3d')
ax2 = fig.add_subplot(122, projection='3d')
ax = [ax1, ax2]
params = [[0.2, 0.2, 0.2], [0.8,0.8,0.8], [0.2,0.5,1]]
for i in range(2):
plot_surface(dirichlet(params[i]), ax[i], log_plot=True, cmap='jet')
ax[i].view_init(10, -135)
ax[i].set_title("Dir({})".format(params[i]), fontsize=20)
ax[i].zaxis.set_rotate_label(False)
ax[i].set_zlabel("$log(f_\mathregular{P}(\mathregular{p}))$",
fontsize=18, weight="bold", style="italic",
labelpad=5, rotation=90)
ax[i].set_xlim([-0.15, 1.1])
ax[i].set_ylim([-0.15, 1.1])
ax[i].set_zlim([0, 17])
ax[i].xaxis.set_ticklabels([])
ax[i].yaxis.set_ticklabels([])
ax[i].set_xticks([])
ax[i].set_yticks([])
ax[i].text(-0.09, -0.07, 0, "$p_1$", fontsize=14)
ax[i].text(1.07, 0.03, 0, "$p_2$", fontsize=14)
ax[i].text(0.5, H+0.22, 0, "$p_3$", fontsize=14)
plt.show()

图 11
请注意,当所有αᵢ小于 1 时,联合 PDF 有一个凸面的表面。PDF 在三角形简单形体的边缘和侧面几乎非常小。它几乎像在三角形的边上有三个 1 维的简单形体。因此,具有这种分布的先验表示一个设置,其中一个或两个pᵢ非常小,它们对应的事件发生的概率很小。
通过比较 Dir([0.2 0.2 0.2]ᵀ)和 Dir([0.8 0.8 0.8]ᵀ),你会发现增加αᵢ的值倾向于使联合 PDF 的表面变平。因此,它减少了边缘和侧面的联合 PDF 值,并增加了在简单形体中部区域的值。
最后,需要注意的是,Dirichlet 分布的参数也可以是非整数的。但例如 Dir([1.65 6 20]ᵀ)是什么意思呢?在这里,我们可以将参数的小数部分分配给先验分布。例如,我们可以将其写成 Dir([0.65+1 1+5 7+13]ᵀ)。这意味着我们从 Dir([0.65 1 7]ᵀ)作为先验分布开始(Dir([0.65 1 7]ᵀ)的联合 PDF 如图 10 所示)。选择这个先验分布意味着我们最初认为p₁几乎为零,它对应的事件发生的可能性非常小。然后我们观察到第一个事件只发生了一次,而第二个和第三个事件分别发生了 5 次和 13 次。这些数字被加到先验分布的参数中,形成了后验分布。
Python 中的贝叶斯推断
现在我们可以绘制轮廓图了,我们可以使用狄利克雷分布来推断多项式分布的参数分布。假设我们有一个 3 面的骰子(当然,它也可以是一个 6 面的骰子,只是上面有 3 个标签(1、2 和 3),每个标签出现在两个面上)。设获得面i的概率为pᵢ,Xᵢ表示观察到面i的总次数(i=1..3)。如前所述,随机向量

具有参数n的多项式分布

设实际的p值为:

因此,这不是一个公平的骰子!我们可以使用scipy中的多项式对象来建模这个分布。以下代码片段显示了掷这个骰子 10 次的结果:
p_act = np.array([0.6, 0.2, 0.2])
sample = multinomial.rvs(n=10, p=p_act, random_state=1)
sample
array([6, 3, 1])
因此,如果我们掷 10 次,我们会得到以下观察结果:
-
面 1:6 次
-
面 2:3 次
-
面 3:仅 1 次
当然,这些是一些随机事件,因此如果我们在rvs()中更改random_state,我们可以获得不同的观察结果(我们固定random_state以使这个特定的观察结果可重复)。
现在假设我们不知道每一面的概率,因此实际的向量p(如方程 10 所示)的值未知。然而,我们仍然可以掷这个骰子n次并观察结果,因此,我们知道Xᵢ的值。如果我们假设未知的概率向量p由随机向量P表示,我们可以使用狄利克雷分布来推断掷骰子后的P的概率分布。
列表 9 首先生成掷骰子n次的结果并存储在m中。然后计算f_P|X (p|X=m),这是后验分布的联合 PDF,并在二维简单形上绘制其轮廓。我们尝试了 5 个不同的n值,范围从 3 到 10000,图 12 展示了这些图形。我们以 Dir([1 1 1]ᵀ)作为P的先验分布。因此,最初我们对P有均匀分布,其中不同的P值是同样可能的。因此,我们对P有最大的未知。
用于生成观察数据的实际P值(方程 10)在这些图中用白色标记显示。随着n的增加,我们获得了更多的观察数据,我们对P的未知性减少。通过增加n,狄利克雷分布从最初的均匀分布变得更加尖锐,并更接近表示p_act 的白色标记。
# Listing 9
p_act_coords = bc_to_cart(p_act)
alpha_prior = [1, 1, 1]
number_rolls = [3, 15, 50, 500, 10000]
num_cols = 2
fig, axes = plt.subplots(3, num_cols, figsize=(16, 25))
plt.subplots_adjust(wspace=0.2, hspace=0.05)
contours = plot_contours(dirichlet(alpha_prior), ax=axes[0, 0])
axes[0, 0].set_title("Prior distribution", fontsize=22, pad=50)
axes[0, 0].scatter(p_act_coords[0],
p_act_coords[1],
s=300, color='white',
marker='+')
axes[0, 0].text(0-0.02, -0.05, "$p_1$", fontsize=16)
axes[0, 0].text(1-0.02, -0.05, "$p_2$", fontsize=16)
axes[0, 0].text(0.5-0.02, H+0.05, "$p_3$", fontsize=16)
divider = make_axes_locatable(axes[0, 0])
cax = divider.append_axes('right', size='2%', pad=0.2)
cbar = fig.colorbar(contours, cax=cax)
for i in range(1, 6):
m= multinomial.rvs(n=number_rolls[i-1], p=p_act, random_state=0)
contours = plot_contours(dirichlet(m + alpha_prior),
ax=axes[i // num_cols, i % num_cols])
axes[i//num_cols, i%num_cols].set_title("n={}".format(number_rolls[i-1]),
fontsize=22, pad=50)
axes[i//num_cols, i%num_cols].scatter(p_act_coords[0],
p_act_coords[1],
s=300, color='white',
marker='+')
axes[i//num_cols, i%num_cols].text(0-0.02, -0.05,
"$p_1$", fontsize=16)
axes[i//num_cols, i%num_cols].text(1-0.02, -0.05,
"$p_2$", fontsize=16)
axes[i//num_cols, i%num_cols].text(0.5-0.02, H+0.05,
"$p_3$", fontsize=16)
divider = make_axes_locatable(axes[i // num_cols, i % num_cols])
cax = divider.append_axes('right', size='2%', pad=0.2)
cbar = fig.colorbar(contours, cax=cax)
plt.show()

图 12
与贝塔分布的关系
让随机向量

具有参数的狄利克雷分布

基于方程 1,X的联合 PDF 是:

其中

由于我们有

我们可以从 PDF 中去除 x₂:

如你所见,X₁ 和 X₂ 的联合 PDF 仅是 x₁ 的函数。因此,随机向量 X 由单一随机变量 X₁ 决定,这意味着上式右侧也是随机变量 X₁ 的 PDF。所以,我们可以写成:

具有这种 PDF 的连续随机变量称为具有参数 α₁ 和 α₂ 的 贝塔分布,我们用 X₁ ~ Beta(α₁, α₂) 来表示。类似地,我们可以用 x₂ 表达 PDF:

因此,我们得出结论 X₂ ~ Beta(α₂, α₁),并得出:

X₁ 和 X₂ 的分布被称为 X 的 边际分布。当 α 仅有两个元素,并且我们仅考虑 X 中的一个随机变量时,贝塔分布是狄利克雷分布的特例。因此,它是一个单变量分布。列表 10 绘制了 Dir([5 1]ᵀ) 的联合 PDF 以及其边际分布的 PDF:Beta(5,1) 和 Beta(1,5)。这些图示见图 13。
# Listing 10
N = 1000
simplex_edges = np.array([[1,0], [0,1]])
tol=1e-6
gamma1 = np.linspace(tol, 1-tol, N)
gamma2 = 1-gamma1
bc_coords = np.stack((gamma1, gamma2), axis=-1)
cart_coords = gamma1.reshape(-1,1)*simplex_edges[0] + \
gamma2.reshape(-1,1)*simplex_edges[1]
alpha = [5, 1]
pdf = [dirichlet(alpha).pdf(x) for x in bc_coords]
x = np.arange(0, 1.01, 0.01)
param_list = [(1,1), (2,2), (5,1)]
beta_dist1 = beta.pdf(x=x, a=alpha[0], b=alpha[1])
beta_dist2 = beta.pdf(x=x, a=alpha[1], b=alpha[0])
fig = plt.figure(figsize=(15, 15))
plt.subplots_adjust(wspace=0.2, hspace=0.1)
gs = gridspec.GridSpec(2, 2, width_ratios=[2.5, 1],
height_ratios=[1, 2.5])
ax1 = fig.add_subplot(221, projection='3d')
ax2 = fig.add_subplot(222)
ax3 = fig.add_subplot(223)
ax1.plot(simplex_edges[:,0], simplex_edges[:,1],
[0,0], color = 'gray', label='1-d Simplex')
ax1.plot(cart_coords[:,0], cart_coords[:,1], pdf, color = 'black',
label='Dir([{},{}])'.format(alpha[0], alpha[1]))
ax1.plot(x, [0]*len(x), beta_dist1, color = 'blue',
label='Beta({},{})'.format(alpha[0], alpha[1]))
ax1.plot([0]*len(x), x, beta_dist2, color = 'green',
label='Beta({},{})'.format(alpha[1], alpha[0]))
ax1.view_init(25, -135)
ax1.set_xlabel("$x_1$", fontsize=18)
ax1.set_ylabel("$x_2$", fontsize=18, labelpad= 9)
ax1.set_zlabel("$f_\mathregular{X}(\mathregular{x})$", fontsize=18,
weight="bold", style="italic",
labelpad= 2, rotation = 45)
ax1.set_xlim([0, 1])
ax1.set_ylim([0, 1])
ax1.set_zlim([0, 6])
ax1.grid(False)
ax1.legend(loc='best', fontsize= 14)
ax2.plot(x, beta_dist1, label='Beta({},{})'.format(alpha[0],
alpha[1]), linewidth=2, color='blue')
ax2.set_xlabel('$x_1$', fontsize=18)
ax2.set_ylabel('$f_{X_1}(x_1)$', fontsize=18)
ax2.legend(loc='upper left', fontsize= 16)
ax2.set_xlim([0,1])
ax2.tick_params(axis='both', which='major', labelsize=12)
ax3.plot(x, beta_dist2, label='Beta({},{})'.format(alpha[0],
alpha[1]), linewidth=2, color='blue')
ax3.set_xlabel('$x_2$', fontsize=18)
ax3.set_ylabel('$f_{X_2}(x_2)$', fontsize=18)
ax3.legend(loc='upper right', fontsize= 16)
ax3.set_xlim([0,1])
ax3.tick_params(axis='both', which='major', labelsize=12)
plt.show()

图 13
请注意,方程 11 中 Dir([α₁ α₂]ᵀ) 的联合 PDF 与方程 12 中其边际分布 (Beta(α₁, α₂)) 的 PDF 相同。然而,它们并不表示相同的分布。前者是随机向量 X 的联合 PDF,而后者是随机变量 X₁ 的 PDF。如图 13 所示,边际分布的 PDFs 是联合 PDF 在由坐标轴 (x₁, fₓ(x)) 和 (x₂, fₓ(x)) 形成的平面上的投影。
请记住,多项分布可以用于建模一个 k 面的骰子。当 k=2 时,骰子变成了硬币。现在 X₁ 可以表示在 n 次掷硬币中的正面总数。类似地,X₂ 表示反面总数。从方程 4,我们得到:

由于 x₁+x₂=n,p₁+p₂=1,我们可以从上述方程中消去 p₂ 和 x₂:

现在随机向量 X 由单一随机变量 X₁ 决定,这意味着上式右侧也是随机变量 X₁ 的 PDF。因此,X₁ 的 PDF 可以写成:

这是 binomial 分布的 PDF。binomial 分布是 multinomial 分布的特例,当随机向量 X 只有一个元素时(即 multinomial 分布的边际分布)。因此 X₁ 具有参数 n 和 p₁ 的 binomial 分布。类似地,X₂ 具有参数 n 和 p₂ 的 binomial 分布。

由于 beta 分布和 binomial 分布分别是 Dirichlet 和 multinomial 分布的特例,它们仍然是共轭分布。实际上,beta 分布是 binomial 分布的共轭先验,如图 14 所示。

图 14(作者提供的图片)
假设我们有一枚硬币,其正面朝上的概率为未知的 p。令随机变量 P 表示未知的概率 p,随机变量 X 表示 n 次掷硬币中正面的总数。假设 P 的概率分布是 Beta(a, b)(这是我们的先验分布)。现在如果我们掷硬币 n 次,观察到 X=k,则 P 的后验分布是 Beta(a+k, b+n-k)。
聚合性质
令随机向量 X 具有以下 Dirichlet 分布:

我们从X中移除随机变量 Xᵢ 和 X_j,并将 Xᵢ+X_j 插入到任意位置,得到的结果随机向量称为 X’。可以证明,X’ 具有以下 Dirichlet 分布:

因此,为了创建新 Dirichlet 分布中参数的向量,首先,我们移除 Xᵢ 和 X_j 对应的参数 (αᵢ 和 α_j),然后在 Xᵢ +X_j 被插入到 X 的相同位置时,插入 αᵢ+α_j(αᵢ+α_j 和 Xᵢ +X_j 在它们对应的向量中的索引是相同的)。聚合性质的证明见附录。
我们来看一个例子。设 X ~ Dir([1 5 3]ᵀ)。利用聚合性质,我们有:

列表 11 显示了所有这些分布的联合 PDF。该图如图 15 所示。在此图中,每个聚合随机向量 [Xᵢ X_j+X_k]ᵀ 具有 1 维单纯形。这里,我们假设该单纯形沿着经过 Xᵢ 的三角形的高度。
# Listing 11
N = 1000
alpha = [1, 5, 3]
edges_marg_x1 = np.array([[0,0], [0.75,0.5*np.cos(pi/6)]])
edges_marg_x2 = np.array([[1,0], [0.25,0.5*np.cos(pi/6)]])
edges_marg_x3 = np.array([[0.5,H], [0.5,0]])
tol=1e-6
gamma1 = np.linspace(tol, 1-tol, N)
gamma2 = 1-gamma1
bc_coords = np.stack((gamma1, gamma2), axis=-1)
marg_x1_cart_coords = gamma1.reshape(-1,1)*edges_marg_x1[0] + \
gamma2.reshape(-1,1)*edges_marg_x1[1]
marg_x2_cart_coords = gamma1.reshape(-1,1)*edges_marg_x2[0] + \
gamma2.reshape(-1,1)*edges_marg_x2[1]
marg_x3_cart_coords = gamma1.reshape(-1,1)*edges_marg_x3[0] + \
gamma2.reshape(-1,1)*edges_marg_x3[1]
alpha_agg1 = [alpha[0], alpha[1]+alpha[2]]
alpha_agg2 = [alpha[1], alpha[0]+alpha[2]]
alpha_agg3 = [alpha[2], alpha[0]+alpha[1]]
pdf1 = [dirichlet(alpha_agg1).pdf(x) for x in bc_coords]
pdf2 = [dirichlet(alpha_agg2).pdf(x) for x in bc_coords]
pdf3 = [dirichlet(alpha_agg3).pdf(x) for x in bc_coords]
fig = plt.figure(figsize=(10, 10))
ax = fig.add_subplot(111, projection='3d')
plot_surface(dirichlet(alpha), ax, antialiased=False,
color='yellow', alpha=0.15)
ax.plot([1,0.5], [0, H], [0, 0], "--", color='black')
ax.plot(marg_x1_cart_coords[:,0], marg_x1_cart_coords[:,1],
pdf1, color = 'black', zorder=10,
label="$[x_1, x_2+x_3]$ ~ Dir([{},{}])".format(alpha_agg1[0],
alpha_agg1[1]))
ax.plot(marg_x2_cart_coords[:,0], marg_x2_cart_coords[:,1],
pdf2, color = 'blue', zorder=12,
label="$[x_2, x_1+x_3]$ ~ Dir([{},{}])".format(alpha_agg2[0],
alpha_agg2[1]))
ax.plot(marg_x3_cart_coords[:,0], marg_x3_cart_coords[:,1],
pdf3, color = 'red', zorder=10,
label="$[x_3, x_1+x_2]$ ~ Dir([{},{}])".format(alpha_agg3[0],
alpha_agg3[1]))
ax.view_init(30, -130)
ax.set_title("Dir([{},{},{}])".format(alpha[0], alpha[1],
alpha[2]), fontsize=18)
ax.zaxis.set_rotate_label(False)
ax.set_zlabel("$f_\mathregular{X}(\mathregular{x})$", fontsize=18,
weight="bold", style="italic", labelpad=15)
ax.set_zlim([0, 17])
ax.xaxis.set_ticklabels([])
ax.yaxis.set_ticklabels([])
ax.set_xticks([])
ax.set_yticks([])
ax.legend(loc='best', fontsize=15)
ax.text(-0.06, -0.03, 0, "$x_1$", fontsize=17)
ax.text(1.03, 0.03, 0, "$x_2$", fontsize=17)
ax.text(0.5, H+0.09, 0, "$x_3$", fontsize=17)
plt.show()

图 15
边际分布
现在,利用聚合性质,当 X 具有超过 2 个元素时,我们可以找到 Dirichlet 分布的边际分布。令 X 具有 Dirichlet 分布:

我们可以对除 X₁ 外的 X 中的所有元素重复应用聚合性质,得到:

我们可以将前面的方程写成

其中

更一般地,我们可以为每个元素X写出相同的方程:

因此,根据方程 13,每个 Xᵢ 的边际分布是以下贝塔分布:

在这篇文章中,我们回顾了狄利克雷分布。我们展示了它是多项分布的共轭先验,并且由于这一重要特性,它可以用来推断多项分布的参数。我们还展示了如何在 Python 中对其进行建模以及如何可视化其联合 PDF。最后,我们看到贝塔分布和狄利克雷分布之间的联系,并展示了狄利克雷分布是贝塔分布在更高维度上的推广。
我希望你喜欢阅读这篇文章。如果你有任何问题或建议,请告诉我。本文中的所有代码清单可以从 GitHub 上以 Jupyter Notebook 形式下载,网址为:
github.com/reza-bagheri/probability_distributions/blob/main/dirichlet_distribution.ipynb
通过物理信息神经网络和符号回归发现微分方程
一个逐步代码实现的案例研究
·发表于Towards Data Science ·阅读时间 25 分钟·2023 年 7 月 28 日
--

照片由Steven Coffey拍摄,来源于Unsplash
微分方程作为一个强大的框架,用于捕捉和理解物理系统的动态行为。通过描述变量之间如何变化,它们提供了对系统动态的见解,并允许我们对系统未来的行为进行预测。
然而,我们在许多实际系统中面临的一个共同挑战是,它们的控制微分方程通常仅部分已知,未知的方面以几种方式表现出来:
-
微分方程的参数是未知的。例如在风工程中,流体动力学的控制方程已被很好地建立,但与湍流流动相关的系数非常不确定。
-
微分方程的函数形式是未知的。例如,在化学工程中,由于速率决定步骤和反应途径的不确定性,速率方程的确切函数形式可能没有完全理解。
-
函数形式和参数都是未知的。一个典型的例子是电池状态建模,其中常用的等效电路模型仅部分捕捉了电流-电压关系(因此缺失物理的函数形式是未知的)。此外,模型本身包含未知的参数(即电阻和电容值)。

图 1. 许多实际动态系统的控制方程仅部分已知。(图片由本博客作者提供)
对主方程的这种部分了解阻碍了我们对这些动力系统的理解和控制。因此,根据观察数据推断这些未知组件成为动力系统建模中的关键任务。
广义而言,使用观察数据恢复动力系统的主方程的过程属于系统识别的范畴。一旦发现这些方程,我们可以轻松地利用这些方程预测系统的未来状态,告知系统的控制策略,或通过分析技术进行理论研究。
最近,Zhang et al.(2023)提出了一种有前景的策略,该策略利用物理信息神经网络(PINN)和符号回归来发现常微分方程(ODEs)系统中的未知量。虽然他们的重点是发现用于阿尔茨海默病建模的微分方程,但他们提出的解决方案对一般动力系统也具有潜力。
在这篇博客文章中,我们将更深入地了解作者提出的概念,并动手重现论文中的一个案例研究。为此,我们将从零开始构建一个 PINN,利用PySR 库进行符号回归,并讨论获得的结果。
如果你对物理信息神经网络的最佳实践感兴趣,欢迎查看我的博客系列:
物理信息神经网络:以应用为中心的指南
记住这一点,让我们开始吧!
目录
· 1. 案例研究 · 2. 为什么传统方法不够有效? · 3. PINN 在系统识别中的应用(理论) · 4. PINN 在系统识别中的应用(代码)
∘ 4.1 定义架构
∘ 4.2 定义 ODE 损失
∘ 4.3 定义梯度下降步骤
∘ 4.4 数据准备
∘ 4.5 PINN 训练
· 5. 符号回归
∘ 5.1 PySR 库
∘ 5.2 实施
∘ 5.3 识别结果
·6. 总结
· 参考文献
1. 案例研究
让我们开始介绍我们旨在解决的问题。在这篇博客中,我们将重现Zhang et al原始论文中的第一个案例研究,即从数据中发现 Kraichnan-Orszag 系统。该系统由以下 ODEs 描述:

具有初始条件 u₁(0)=1,u₂(0)=0.8,u₃(0)=0.5。Kraichnan-Orszag 系统通常用于湍流研究和流体动力学研究,其目标是对湍流及其结构和动态发展理论见解。
为了模拟一个典型的系统识别设置,我们假设我们对控制常微分方程的了解仅限于部分已知。具体来说,我们假设我们对 u₁ 和 u₂ 的微分方程一无所知。此外,我们假设我们只知道 u₃ 的微分方程右侧是 u₁ 和 u₂ 的线性变换。然后,我们可以将常微分方程系统重写如下:

其中 f₁ 和 f₂ 代表未知函数,a 和 b 是未知参数。我们的目标是校准 a 和 b 的值,并估计 f₁ 和 f₂ 的解析函数形式。 本质上,我们正面临一个具有未知参数和函数形式的复杂系统识别问题。
2. 为什么传统方法会失败?
在传统的系统识别范式中,我们通常使用数值方法(例如,欧拉法、龙格-库塔法等)来模拟和预测系统状态 u₁、u₂ 和 u₃。 然而,这些方法从根本上受限,因为它们通常需要完整的控制微分方程形式,并且无法处理微分方程仅部分已知的情况。
在方程参数未知的情况下,传统方法通常诉诸于优化技术,其中对参数进行初步猜测,然后通过迭代过程来优化,以最小化观察数据与数值求解器预测数据之间的差异。由于每次优化迭代都需要运行一次数值求解器,这种方法虽然可行,但计算开销可能非常大。
请注意,上述讨论仅描述了校准未知参数的情况。当我们需要估计微分方程中的未知函数时,问题变得更加复杂。从理论上讲,我们可以采用类似的方法,即在优化之前对未知函数的形式做出假设。然而,如果我们走这条路,会立即出现问题:如果我们假设一个过于简单的形式,我们面临欠拟合的风险,这可能导致较大的预测误差。另一方面,如果我们假设一个过于复杂的形式(例如,具有许多可调参数),我们面临过拟合的风险,这可能导致较差的泛化性能。
总之,传统方法在处理部分已知微分方程时面临重大挑战:
1️⃣ 传统数值方法依赖于具有完整控制微分方程的形式来进行模拟。
2️⃣ 将传统数值方法与优化算法结合可以解决参数估计问题,但通常代价很高。
3️⃣ 对于嵌入微分方程中的未知函数进行估计时,传统方法可能会得到对假设函数形式高度敏感的结果,这会导致欠拟合或过拟合的风险。
鉴于这些挑战,传统方法在处理未知参数和函数形式共存的系统识别问题时往往效果不佳。这自然引出了物理信息神经网络(PINNs)的话题。在下一节中,我们将看到 PINN 如何有效地解决传统方法面临的挑战。
3. PINN 在系统识别中的应用(理论)
物理信息神经网络(简称 PINN)是Raissi 等人在 2019 年提出的一个强大概念。PINN 的基本思想,像其他物理信息机器学习技术一样,是创建一个混合模型,其中在模型训练中利用了观察数据和已知的物理知识(以微分方程形式表示)。PINN 最初被设计为一个高效的 ODE/PDE 求解器。然而,研究人员很快认识到 PINN 在解决逆问题和系统识别问题上(可以说)具有更大的潜力。
在接下来的内容中,我们将逐一解释如何利用 PINN 克服我们在上一节讨论的挑战。
1️⃣ 传统数值方法依赖于拥有完整形式的主控微分方程来进行模拟。
📣PINN 的响应:与传统方法不同,我能够处理部分已知的微分方程,因此不受完整方程的限制来进行模拟。
从外部角度看,PINN 仅仅类似于一个传统的神经网络模型,该模型将时间/空间坐标(例如,t,x,y)作为输入,并输出我们试图模拟的目标量(例如,速度u,压力p,温度T等)。然而,使 PINN 与传统 NN 不同的是,在 PINN 中,微分方程作为训练过程中的约束。具体来说,PINN 引入了一个额外的损失项,用于计算主控微分方程的残差,该残差通过将预测量代入主控方程计算得到。通过优化这个损失项,我们有效地使训练后的网络意识到潜在的物理规律。

图 2. 物理信息神经网络将微分方程纳入损失函数中,因此有效地使训练后的网络意识到潜在的物理规律。(图像由本博客作者提供)
由于微分方程仅用于构建损失函数,因此它们对 PINN 模型结构没有影响。这实际上意味着我们在训练时不需要对微分方程有完全的了解。即使我们只知道方程的一部分,这些知识仍然可以被纳入以强制输出遵循已知的物理规律。这种适应知识完整度不同的灵活性相比传统数值方法具有显著优势。
2️⃣ 结合传统数值方法与优化算法可以解决参数估计问题,但通常代价较高。
📣PINN 的回应:我可以提供一种计算上高效的替代方案来估计未知参数。
与将参数估计视为单独优化任务的传统方法不同,PINNs 将这一过程无缝地集成到模型训练阶段。在 PINNs 中,未知参数被简单地视为额外的可训练参数,这些参数在训练过程中与其他神经网络的权重和偏差一起优化。

图 3. 未知参数与 PINN 的权重和偏差一起优化。在训练结束时,我们得到的最终值 a 和 b 作为未知参数的估计值。(图片由本博客作者提供)
此外,PINNs 充分利用现代深度学习框架来执行训练。这允许快速计算所需的梯度(即通过自动微分),以用于高级优化算法(例如 Adam),从而大大加速了参数估计过程,尤其是对于高维参数空间的问题。这些因素使得 PINNs 成为参数估计问题的一个有竞争力的替代方案。
3️⃣ 对于嵌入微分方程中的未知函数,传统方法可能会得到对假设函数形式高度敏感的结果,这会产生欠拟合或过拟合的风险。
📣PINN 的回应:未知函数可以通过额外的神经网络有效地参数化,这些神经网络可以与我一起训练,就像之前的参数估计场景一样。
我们可以用独立的神经网络来逼近未知函数,然后将它们集成到主 PINN 模型中。就像在之前的参数估计场景中一样,我们可以将这些额外的神经网络视为需要估计的大量未知参数。

图 4。未知函数可以通过一个独立的神经网络进行参数化,并与原始 PINN 一起训练。ODE/PDE 残差损失项对辅助神经网络进行正则化,以满足控制方程。这样,辅助神经网络可以直接从数据中自动学习最佳的函数形式。(图像来源于本博客作者)
在训练过程中,这些辅助神经网络的权重和偏差将与原始 PINN 同时训练,以最小化损失函数(数据损失 + ODE 残差损失)。通过这种方式,这些辅助神经网络可以直接从数据中学习最佳的函数形式。通过消除对函数形式进行风险假设的需要,这种策略有助于缓解欠拟合和过拟合的问题。
总结来说,PINN 的优势在于其能够处理部分已知的微分方程,并有效地从数据中学习未知参数和函数形式。这种多功能性使其与传统方法区别开来,因此成为系统识别任务的有效工具。
在下一节中,我们将开始处理我们的案例研究,并将理论转化为实际代码。
4. PINN 用于系统识别(代码)
在本节中,我们将实现一个 PINN(在 TensorFlow 中)来解决我们的目标案例研究。让我们从导入必要的库开始:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp
import tensorflow as tf
from tensorflow import keras
tf.random.set_seed(42)
4.1 定义架构
对于主要的 PINN,我们使用一个神经网络来预测u,其具有 1 维输入(即t)和 3 维输出(u₁、u₂和u₃)。此外,如前一节所讨论的,我们使用一个辅助神经网络来逼近未知函数f₁和f₂,该网络具有 4 维输入(即t、u₁、u₂和u₃)和 2 维输出(f₁和f₂)。整体 PINN 的架构如下所示:

图 5。所使用的 PINN 模型的架构。(图像来源于本博客作者)
值得再次强调的是,需要向辅助神经网络提供所有可用的特征(在我们当前的情况下,t、u₁、u₂和u₃),因为我们不知道f₁和f₂的确切函数形式。在训练过程中,辅助神经网络将以数据驱动的方式自动确定哪些特征是必要的/重要的。
首先,让我们定义一个预测u的神经网络。在这里,我们使用两个隐藏层,每个层配备 50 个神经元和双曲正切激活函数:
def u_net(u_input):
"""Definition of the network for u prediction.
Args:
----
u_input: input for the u-net
Outputs:
--------
output: the output of u-net
"""
hidden = u_input
for _ in range(2):
hidden = tf.keras.layers.Dense(50, activation="tanh")(hidden)
output = tf.keras.layers.Dense(3)(hidden)
return output
接下来,我们定义一个预测f的辅助神经网络。我们采用相同的网络架构:
def f_net(f_inputs, a_init=None, b_init=None):
"""Definition of the network for f prediction.
Args:
----
f_inputs: list of inputs for the f-net
a_init: initial value for parameter a
b_init: initial value for parameter b
Outputs:
--------
output: the output of f-net
"""
hidden = tf.keras.layers.Concatenate()(f_inputs)
for _ in range(2):
hidden = tf.keras.layers.Dense(50, activation="tanh")(hidden)
output = tf.keras.layers.Dense(2)(hidden)
output = ParameterLayer(a_init, b_init)(output)
return output
在上述代码中,我们将a和b添加到神经网络模型参数的集合中。这样,a和b可以与神经网络的其他权重和偏差一起优化。我们通过定义一个自定义层ParameterLayer实现了这一目标:
class ParameterLayer(tf.keras.layers.Layer):
def __init__(self, a, b, trainable=True):
super(ParameterLayer, self).__init__()
self._a = tf.convert_to_tensor(a, dtype=tf.float32)
self._b = tf.convert_to_tensor(b, dtype=tf.float32)
self.trainable = trainable
def build(self, input_shape):
self.a = self.add_weight("a", shape=(1,),
initializer=tf.keras.initializers.Constant(value=self._a),
trainable=self.trainable)
self.b = self.add_weight("b", shape=(1,),
initializer=tf.keras.initializers.Constant(value=self._b),
trainable=self.trainable)
def get_config(self):
return super().get_config()
@classmethod
def from_config(cls, config):
return cls(**config)
注意,这一层除了引入这两个参数作为模型属性外没有其他作用。
最后,我们将 u-net 和 f-net 结合在一起,定义完整的 PINN 架构:
def create_PINN(a_init=None, b_init=None, verbose=False):
"""Definition of a PINN.
Args:
----
a_init: initial value for parameter a
b_init: initial value for parameter b
verbose: boolean, indicate whether to show the model summary
Outputs:
--------
model: the PINN model
"""
# Input
t_input = tf.keras.Input(shape=(1,), name="time")
# u-NN
u = u_net(t_input)
# f-NN
f = f_net([t_input, u], a_init, b_init)
# PINN model
model = tf.keras.models.Model(inputs=t_input, outputs=[u, f])
if verbose:
model.summary()
return model
在上述代码中,我们将输入 t 和 u-net 输出 u₁, u₂, 和 u₃ 进行串联,然后输入到 f-net 中。此外,我们在整体 PINN 模型中输出 u 和 f。虽然在实际应用中只需要 u(因为 u 是我们的建模目标),但后续 f 的预测会变得有用,以提取其分析函数形式(见第五部分)。
4.2 定义 ODE 损失
接下来,我们定义计算 ODE 残差损失的函数。回顾一下,我们的目标 ODEs 是:

因此,我们可以按如下方式定义函数:
@tf.function
def ODE_residual_calculator(t, model):
"""ODE residual calculation.
Args:
----
t: temporal coordinate
model: PINN model
Outputs:
--------
ODE_residual: residual of the governing ODE
"""
# Retrieve parameters
a = model.layers[-1].a
b = model.layers[-1].b
with tf.GradientTape() as tape:
tape.watch(t)
u, f = model(t)
# Calculate gradients
dudt = tape.batch_jacobian(u, t)[:, :, 0]
du1_dt, du2_dt, du3_dt = dudt[:, :1], dudt[:, 1:2], dudt[:, 2:]
# Compute residuals
res1 = du1_dt - f[:, :1]
res2 = du2_dt - f[:, 1:]
res3 = du3_dt - (a*u[:, :1]*u[:, 1:2] + b)
ODE_residual = tf.concat([res1, res2, res3], axis=1)
return ODE_residual
虽然上述代码大部分是自解释的,但有几个问题值得提及:
-
我们使用了
tf.GradientTape.batch_jacobian()(而不是通常的GradientTape.gradient())来计算 u₁, u₂ 和 u₃ 相对于 t 的梯度。GradientTape.gradient()在这里不起作用,因为它计算的是 du₁/dt + du₂/dt + du₃/dt。我们也可以在这里使用GradientTape.jacobian()来计算每个输出值相对于每个输入值的梯度。有关更多细节,请参见 官方页面。 -
我们使用了
@tf.function装饰器将上述 Python 函数转换为 TensorFlow 图。这是有用的,因为梯度计算可能非常昂贵,使用图模式执行可以显著加速计算。
4.3 定义梯度下降步骤
接下来,我们配置了计算总损失相对于参数(网络权重和偏差,以及未知参数 a 和 b)的梯度的逻辑。这对于执行模型训练的梯度下降是必要的:
@tf.function
def train_step(X_ODE, X, y, IC_weight, ODE_weight, data_weight, model):
"""Calculate gradients of the total loss with respect to network model parameters.
Args:
----
X_ODE: collocation points for evaluating ODE residuals
X: observed samples
y: target values of the observed samples
IC_weight: weight for initial condition loss
ODE_weight: weight for ODE loss
data_weight: weight for data loss
model: PINN model
Outputs:
--------
ODE_loss: calculated ODE loss
IC_loss: calculated initial condition loss
data_loss: calculated data loss
total_loss: weighted sum of ODE loss, initial condition loss, and data loss
gradients: gradients of the total loss with respect to network model parameters.
"""
with tf.GradientTape() as tape:
tape.watch(model.trainable_weights)
# Initial condition prediction
y_pred_IC, _ = model(tf.zeros((1, 1)))
# ODE residual
ODE_residual = ODE_residual_calculator(t=X_ODE, model=model)
# Data loss
y_pred_data, _ = model(X)
# Calculate loss
IC_loss = tf.reduce_mean(keras.losses.mean_squared_error(tf.constant([[1.0, 0.8, 0.5]]), y_pred_IC))
ODE_loss = tf.reduce_mean(tf.square(ODE_residual))
data_loss = tf.reduce_mean(keras.losses.mean_squared_error(y, y_pred_data))
# Weight loss
total_loss = IC_loss*IC_weight + ODE_loss*ODE_weight + data_loss*data_weight
gradients = tape.gradient(total_loss, model.trainable_variables)
return ODE_loss, IC_loss, data_loss, total_loss, gradients
在上述代码中:
-
我们考虑三个损失项:初始条件损失
IC_loss、ODE 残差损失ODE_loss和数据损失data_loss。IC_loss通过将模型预测的u(t=0)与已知的u初始值进行比较来计算,ODE_loss通过调用我们之前定义的ODE_residual_calculator函数来计算,而数据损失则是通过将模型预测值(即 u₁, u₂, u₃)与它们的观测值进行简单比较来计算的。 -
我们将总损失定义为
IC_loss、ODE_loss和data_loss的加权和。通常,权重控制在训练过程中对各个损失项的重视程度。在我们的案例研究中,将它们全部设置为 1 就足够了。
4.4 数据准备
在本小节中,我们讨论了如何组织数据以进行 PINN 模型训练。
回忆一下,我们的总损失函数包含 ODE 残差损失和数据损失。因此,我们需要生成时间维度上的配点(用于评估 ODE 损失)和配对输入(t)-输出(u)的监督数据。
# Set batch size
data_batch_size = 100
ODE_batch_size = 1000
# Samples for enforcing ODE residual loss
N_collocation = 10000
X_train_ODE = tf.convert_to_tensor(np.linspace(0, 10, N_collocation).reshape(-1, 1), dtype=tf.float32)
train_ds_ODE = tf.data.Dataset.from_tensor_slices((X_train_ODE))
train_ds_ODE = train_ds_ODE.shuffle(10*N_collocation).batch(ODE_batch_size)
# Samples for enforcing data loss
X_train_data = tf.convert_to_tensor(u_obs[:, :1], dtype=tf.float32)
y_train_data = tf.convert_to_tensor(u_obs[:, 1:], dtype=tf.float32)
train_ds_data = tf.data.Dataset.from_tensor_slices((X_train_data, y_train_data))
train_ds_data = train_ds_data.shuffle(10000).batch(data_batch_size)
在上面的代码中,我们在目标时间域[0, 10]内分配了 10000 个等间距的配点。为了方便数据损失计算,我们预生成了配对输入(t)-输出(u)数据集u_obs,其第一列为时间坐标,其余三列分别表示 u₁、u₂ 和 u₃。u_obs包含 1000 个数据点,计算方式如下代码:
# Set up simulation
u_init = [1, 0.8, 0.5]
t_span = [0, 10]
obs_num = 1000
# Solve ODEs
u_obs = simulate_ODEs(u_init, t_span, obs_num)
其中 simulate_ODEs 是 ODE 求解器,它在给定初始条件和模拟域的情况下模拟u轨迹:
def simulate_ODEs(u_init, t_span, obs_num):
"""Simulate the ODE system and obtain observational data.
Args:
----
u_init: list of initial condition for u1, u2, and u3
t_span: lower and upper time limit for simulation
obs_num: number of observational data points
Outputs:
--------
u_obs: observed data for u's
"""
# Target ODEs
def odes(t, u):
du1dt = np.exp(-t/10) * u[1] * u[2]
du2dt = u[0] * u[2]
du3dt = -2 * u[0] * u[1]
return [du1dt, du2dt, du3dt]
# Solve ODEs
t_eval = np.linspace(t_span[0], t_span[1], obs_num)
sol = solve_ivp(odes, t_span, u_init, method='RK45', t_eval=t_eval)
# Restrcture solution
u_obs = np.column_stack((sol.t, sol.y[0], sol.y[1], sol.y[2]))
return u_obs
下图展示了目标u的轮廓。请注意,我们已经抽取了 1000 个等间距的 (t — u₁)、(t — u₂) 和 (t — u₃) 数据对(包含在u_obs中),作为数据损失计算的监督数据。

图 6. 我们当前研究的 ODE 的输出轮廓。(图像由本博客作者提供)
4.5 PINN 训练
以下代码定义了主要的训练和验证逻辑:
# Set up training configurations
n_epochs = 1000
IC_weight= tf.constant(1.0, dtype=tf.float32)
ODE_weight= tf.constant(1.0, dtype=tf.float32)
data_weight= tf.constant(1.0, dtype=tf.float32)
a_list, b_list = [], []
# Initial value for unknown parameters
a_init, b_init = -1, 1
# Set up optimizer
optimizer = keras.optimizers.Adam(learning_rate=2e-2)
# Instantiate the PINN model
PINN = create_PINN(a_init=a_init, b_init=b_init)
PINN.compile(optimizer=optimizer)
# Configure callbacks
_callbacks = [keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=100),
tf.keras.callbacks.ModelCheckpoint('PINN_model.h5', monitor='val_loss', save_best_only=True)]
callbacks = tf.keras.callbacks.CallbackList(
_callbacks, add_history=False, model=PINN)
# Start training process
for epoch in range(1, n_epochs + 1):
print(f"Epoch {epoch}:")
for (X_ODE), (X, y) in zip(train_ds_ODE, train_ds_data):
# Calculate gradients
ODE_loss, IC_loss, data_loss, total_loss, gradients = train_step(X_ODE, X, y, IC_weight,
ODE_weight, data_weight, PINN)
# Gradient descent
PINN.optimizer.apply_gradients(zip(gradients, PINN.trainable_variables))
# Parameter recording
a_list.append(PINN.layers[-1].a.numpy())
b_list.append(PINN.layers[-1].b.numpy())
####### Validation
val_res = ODE_residual_calculator(tf.reshape(tf.linspace(0.0, 10.0, 1000), [-1, 1]), PINN)
val_ODE = tf.cast(tf.reduce_mean(tf.square(val_res)), tf.float32)
u_init=tf.constant([[1.0, 0.8, 0.5]])
val_pred_init, _ = PINN.predict(tf.zeros((1, 1)))
val_IC = tf.reduce_mean(tf.square(val_pred_init-u_init))
# Callback at the end of epoch
callbacks.on_epoch_end(epoch, logs={'val_loss': val_IC+val_ODE})
# Re-shuffle dataset
train_ds_data = tf.data.Dataset.from_tensor_slices((X_train_data, y_train_data))
train_ds_data = train_ds_data.shuffle(10000).batch(data_batch_size)
train_ds_ODE = tf.data.Dataset.from_tensor_slices((X_train_ODE))
train_ds_ODE = train_ds_ODE.shuffle(10*N_collocation).batch(ODE_batch_size)
-
正如之前讨论的,我们将不同损失组件的权重设置为 1。
-
我们将 a 和 b 的初始猜测设置为-1 和 1,分别。回忆一下,这些值与它们的真实值不同,真实值分别为-2 和 0。
-
为了验证,我们将 ODE 残差损失和初始条件损失相加,作为最终的验证损失。请注意,我们在这里不考虑数据损失,因为我们假设没有额外的配对 t — u 数据集用于验证目的。计算出的验证损失用于调整学习率。
下图展示了损失收敛曲线。我们可以看到所有三个损失组件都正确收敛,这表明训练已满意完成。

图 7. 损失收敛图。(图像由本博客作者提供)
下图展示了预测的u与通过 ODE 求解器计算的真实值之间的比较。在这里,我们还可以看到 PINN 能够准确地解决我们的目标 ODE。

图 8. 预测的u与 ODE 求解器计算的真实值的比较。
然而,训练 PINN 并不是我们的最终目标。相反,我们更感兴趣的是估计我们目标 ODE 中嵌入的未知数。让我们从参数估计开始。下图描绘了 a 和 b 的演变。

图 9. 未知参数 a 和 b 迅速脱离了指定的初始值,并收敛到它们的真实值。这表明所采用的 PINN 策略能够对 ODE 系统进行参数估计。(图片由本博客作者提供)
我们可以清楚地看到,随着训练的进行,a和b的值迅速收敛到各自的真实值。这表明我们的 PINN 策略在参数估计方面是有效的。
除了未知参数外,我们还通过训练好的辅助f-网络获得了未知函数f₁和f₂的估计值。为了检查f₁和f₂的近似精度,我们可以将它们与计算得到的 du₁/dt 和 du₂/dt 进行比较,如下代码所示:
X_test = np.linspace(0, 10, 1000).reshape(-1, 1)
X_test = tf.convert_to_tensor(X_test, dtype=tf.float32)
with tf.GradientTape() as tape:
tape.watch(X_test)
u, f = PINN(X_test)
# Calculate gradients
dudt = tape.batch_jacobian(u, X_test)[:, :, 0]
du1_dt, du2_dt = dudt[:, :1], dudt[:, 1:2]
# Visualize comparison
fig, ax = plt.subplots(1, 2, figsize=(10, 4))
ax[0].scatter(du1_dt.numpy().flatten(), f[:, 0].numpy())
ax[0].set_xlabel('$du_1$/dt', fontsize=14)
ax[0].set_ylabel('$f_1$', fontsize=14)
ax[1].scatter(du2_dt.numpy().flatten(), f[:, 1].numpy())
ax[1].set_xlabel('$du_2$/dt', fontsize=14)
ax[1].set_ylabel('$f_2$', fontsize=14)
for axs in ax:
axs.tick_params(axis='both', which='major', labelsize=12)
axs.grid(True)
plt.tight_layout()
从下图中我们可以清楚地看到,f-网络的预测完全符合控制 ODE,这与之前观察到的 ODE 残差非常小的情况一致。

图 10. 计算出的导数与预测的f函数值的比较。
尽管我们可以用f-网络准确地逼近未知函数f₁和f₂,但归根结底,f-网络是一个黑箱神经网络模型。自然地,我们会想问:这些估计函数的确切功能形式是什么?这个答案可以为我们提供对潜在物理过程的更深入理解,并帮助我们将结果推广到其他类似的问题。
那么,我们如何从训练好的神经网络模型中提取这些精确的功能形式呢?我们将在下一节中探讨这个问题。
5. 符号回归
符号回归是一种强大的监督学习技术,可以用来发现最适合给定数据集的潜在数学公式。正如其名称所示,这项技术包括两个关键组成部分:符号和回归:
-
符号指的是使用符号表达式来建模输入输出关系,例如,“+”表示加法,“-”表示减法,“cos”表示余弦函数等。符号回归方法不是拟合预定义模型(例如,多项式模型等),而是通过整个潜在符号表达式的空间进行搜索,以找到最佳拟合。
-
回归指的是创建一个模型以预测输出变量的过程,该过程基于输入变量,从而捕捉它们之间的潜在关系。尽管“回归”一词可能会让人联想到线性回归,但在符号回归的背景下,它并不局限于任何特定的模型形式,而是可以采用各种数学运算符和结构。
在这一部分,我们将实现符号回归技术,将学习到的 f-网络提炼成可解释且紧凑的数学表达式,这与张等人在他们的原始论文中提出的策略一致。我们将首先介绍将用于符号回归的库 PySR。随后,我们将应用这个库解决我们的课题,并讨论超参数的选择。最后,我们将分析获得的结果。
5.1 PySR 库
PySR 是一个开源 Python 库,旨在提供实用的高性能科学符号回归。它使用先进的 evolutionary 优化算法在简单解析表达式的空间中搜索,以获得准确且可解释的模型,从而将预测误差和模型复杂度共同最小化。
尽管 PySR 暴露了一个类似于 scikit-learn 风格的简单 Python 前端 API,但其后台是用纯 Julia 编写的,库名为 SymbolicRegression.jl。这为用户提供了定制操作符和优化损失函数的灵活性,同时享有高计算性能。有关 PySR 工作原理的更多细节,请参见这篇论文。
要开始使用 PySR,你需要首先安装 Julia。然后运行
pip3 install -U pysr
然后通过
python3 -m pysr install
或者在 IPython 中调用
import pysr
pysr.install()
PySR 也可以通过 conda 或 docker 安装。请查看安装页面以获取更多细节。
5.2 实施
接下来,我们应用 PySR 库将学习到的 f-网络提炼成可解释且紧凑的数学表达式。首先,我们需要生成符号回归学习的数据集:
t = np.linspace(0, 10, 10000).reshape(-1, 1)
u, f = PINN.predict(t, batch_size=12800)
# Configure dataframe
df = pd.DataFrame({
't': t.flatten(),
'u1': u[:, 0],
'u2': u[:, 1],
'u3': u[:, 2],
'f1': f[:, 0],
'f2': f[:, 1]
})
df.to_csv('f_NN_IO.csv', index=False)
请注意,对于我们当前的问题,符号回归学习的输入是 t、u₁、u₂ 和 u₃,输出是 f₁ 和 f₂。这是因为在我们的目标 ODE 中,我们假设 f₁=f₁(t、u₁、u₂、u₃) 和 f₂=f₂(t、u₁、u₂、u₃)。我们保存了生成的数据框(见下图)以备后用。

图 11. 生成的符号回归学习数据框。(图片来源:本博客作者)
生成数据集后,我们就可以使用 PySR 进行符号回归了。请注意,建议在终端中运行 PySR 代码,而不是在 Jupyter Notebook 中。尽管 PySR 支持 Jupyter Notebook,但在终端环境中的打印(例如,搜索进度、当前最佳结果等)效果要更好。
按照 scikit-learn 风格,我们首先定义一个模型对象:
from pysr import PySRRegressor
model = PySRRegressor(
niterations=20,
binary_operators=["+", "*"],
unary_operators=[
"cos",
"exp",
"sin",
"inv(x) = 1/x",
],
extra_sympy_mappings={"inv": lambda x: 1 / x},
loss="L1DistLoss()",
model_selection="score",
complexity_of_operators={
"sin": 3, "cos": 3, "exp": 3,
"inv(x) = 1/x": 3
}
)
以下是指定超参数的详细信息:
-
niterations:算法运行的迭代次数。通常,较大的迭代次数会产生更好的结果,但代价是更高的计算成本。然而,由于 PySR 允许提前终止搜索任务,好的做法是将niterations设置为一个非常大的值并保持优化进行。一旦识别出的方程看起来令人满意,就可以提前停止任务。 -
binary_operators:用于搜索的二元运算符字符串列表。PySR 支持的内置二元运算符包括+、-、*、/、^、greater、mod、logical_or、logical_and。 -
unary_operators:用于搜索的一元运算符列表。注意,一元运算符只接受单个标量作为输入。内置的一元运算符包括neg、square、cube、exp、abs、log、log10、log2、log1p、sqrt、sin、cos、tan、sinh、cosh、tanh、atan、asinh、acosh、atanh_clip(=atanh((x+1)%2 - 1))、erf、erfc、gamma、relu、round、floor、ceil、round、sign。注意,要提供自定义运算符,我们需要将“myfunction(x) = …”传递给运算符列表,就像我们用“inv(x) = 1/x”做的那样。 -
extra_sympy_mappings:提供自定义的binary_operators或unary_operators在 julia 字符串中与 sympy 中相同运算符的映射。这在导出结果时非常有用。 -
loss:指定元素级损失函数的 Julia 代码字符串(如在 LossFunctions.jl 中定义)。常用的损失包括L1DistLoss()(绝对距离损失)、L2DistLoss()(最小二乘损失)、HuberLoss()(用于抗离群值的 Huber 损失函数)。损失函数指定了符号回归搜索的优化目标。 -
model_selection:从每个复杂度的最佳表达式列表中选择最终表达式的标准。score意味着候选模型将根据最高得分进行选择,得分定义为 -Δlog(loss)/ΔC,其中 C 代表表达式的复杂度,Δ 表示局部变化。因此,如果一个表达式在稍高的复杂度下具有更好的损失,则更受青睐。 -
complexity_of_operators:默认情况下,所有运算符的复杂度为 1。要更改默认复杂度设置并优先考虑不同的运算符,我们可以提供一个字典,键为运算符字符串,值为其对应的复杂度级别。在我们当前的案例中,我们将所有一元运算符的复杂度级别设置为 3,这也在 Zhang 等人的原始论文中采用。
值得一提的是,PySRRegressor 提供了许多其他超参数,用于设置算法、数据预处理、停止标准、性能和并行化、监控、环境和结果导出。有关控制符号回归搜索的所有选项的完整列表,请查看 PySRRegressor 参考页面。
5.3 识别结果
在指定模型对象后,我们可以用三行代码启动拟合过程(用于提炼 f₁ 的解析形式):
df = pd.read_csv('f_NN_IO.csv')
X = df.iloc[:, :4].to_numpy()
f1 = df.loc[:, 'f1'].to_numpy()
model.fit(X, f1)
在脚本运行时,你应该能够看到进度条和当前最佳方程,如下图所示。注意 x0、x1、x2 和 x3 分别对应 t、u₁、u₂ 和 u₃。

一旦优化任务完成,终端中将出现候选方程列表:

如果我们根据 评分值 对方程进行排名,可以看到排名前三的方程是:
-
u₂ u₃ exp( -0.1053391 t )
-
0.60341805 u₂ u₃
-
u₂ u₃
回忆一下我们真实的 ODE 是

令人印象深刻的是,PySR 准确地识别出了基本输入(即,它识别出 u₁ 在 f₁ 中不起作用),并发现了一个接近 f₁ 真实表达式的解析表达式(排名第一的结果)。
我们对 f₂ 进行了相同的分析。优化结果如下图所示:

这次,我们注意到 f₂ 的真实表达式,即 f₂=u₁ u₃,仅作为第二好的(按评分计算)方程出现。然而,请注意,最佳方程,即 u₃,其得分仅比第二好的高一点。另一方面,u₁ u₃ 的损失值比单独使用 u₃ 低一个数量级。这些观察结果表明,在实际操作中,我们需要领域知识/经验来做出明智的决定,以判断追求高准确度所带来的复杂性是否值得。
6. 关键要点
在这篇博客文章中,我们探讨了从观测数据中发现微分方程的问题。我们遵循了 Zhang 等人提出的策略,将其实现为代码,并应用于一个案例研究。以下是关键要点:
1️⃣ 物理信息神经网络 (PINN) 是一个多用途的工具,用于进行系统识别,特别是在对控制微分方程只有部分信息已知的情况下。通过同化观察数据和现有的物理知识,PINN 不仅能有效估计未知参数,还能估计未知函数,如果我们采用用辅助神经网络对未知函数进行参数化的技巧,并与主 PINN 一起联合训练。这些因素共同作用,相比传统的系统识别方法,具有显著的优势。
2️⃣ 符号回归是一种强大的工具,用于揭开学习神经网络的黑箱。通过利用先进的进化算法在整个符号表达式空间中进行搜索,符号回归能够提取出可解释且紧凑的解析表达式,这些表达式可以准确描述隐藏的输入输出关系。这个知识蒸馏过程在实践中受到高度赞赏,因为它能有效增强我们对基础系统动态的理解。
在我们结束这篇博客之前,有几点在将 PINN+符号回归应用于实际问题时值得考虑:
1️⃣ 不确定性量化 (UQ)
在这篇博客中,我们假设我们观察到的 u₁、u₂ 和 u₃ 数据是无噪声的。然而,这种假设通常不成立,因为实际的动态系统中的观察数据很容易被噪声污染。因此,我们系统识别结果的 准确性 和 可靠性 都会受到影响。因此,一个关键方面是考虑在我们的系统识别工作流中进行不确定性量化。像贝叶斯神经网络和 蒙特卡洛模拟 这样的技术可以合理地考虑观察数据中的噪声,并提供对预测的置信区间的估计。
2️⃣ 符号回归的敏感性
一般来说,符号回归得到的结果可能对所使用的损失函数、提供的单一和二元运算符候选项以及定义的运算符复杂度敏感。例如,在我尝试重现 Zhang 等人发布的结果时,尽管我采用了完全相同的设置(据我所知),但我未能获得与原始论文中所示的 f₂ 完全一致的前 3 个方程。这种不匹配可能有几个因素:首先,进化优化技术本质上是随机的,因此结果可能在不同的运行中有所不同。其次,第一阶段训练的 PINN 可能不同,因此生成的数据集(即 t,u₁,u₂,u₃ → f₁,f₂)也不同,从而影响了符号回归的结果。
总的来说,这些观察结果表明,符号回归的结果不应盲目接受。相反,依赖领域知识/理解来批判性地评估识别出的方程的合理性至关重要。
如果你觉得我的内容有用,可以在这里请我喝咖啡🤗 非常感谢你的支持!
你可以在这里找到带有完整代码的伴随笔记本和脚本💻。
要学习物理信息神经网络的最佳实践,请参阅:解开物理信息神经网络设计模式的奥秘。
要了解更多关于物理信息运算符学习的内容,请参阅:通过物理信息深度运算符学习。
参考资料
[1] Zhang 等,结合 PINN 与符号回归发现阿尔茨海默病的反应扩散模型。arXiv,2023。
[2] Cranmer 等,使用 PySR 和 SymbolicRegression.jl 进行可解释的机器学习科学研究。arXiv,2023。
发现最大流-最小割定理:一种全面而正式的方法
探索流网络领域和最大流-最小割定理
·
关注 发表在 Towards Data Science ·22 分钟阅读·2023 年 8 月 24 日
--
图片由 israel palacio 提供,来源于 Unsplash
引言
在网络流优化领域,Maxflow Mincut 定理作为一个显著的数学里程碑脱颖而出。它的优雅之处在于解决关于流体或资源在由节点和边互联的网络中流动的复杂优化问题。它的应用涵盖了从交通网络到通信基础设施的广泛系统,在这些系统中,高效的流动管理至关重要。通过理解这个定理及其数学表达背后的基本概念,你可以解开最大化资源利用和在各种实际场景中达到最佳性能的谜团。
在本文中,我们旨在简化并使定理对所有读者易于接近。我们将引导你了解其历史发展,概述其从早期公式化的根源,这将使我们能够欣赏到为这一定理及其整个数学研究领域铺平道路的杰出思想者的贡献。此外,我们将深入探讨 Maxflow Mincut 定理的实际应用。从设计高效的交通系统到处理图像处理任务,其多样性似乎无穷无尽。通过探索其实际影响,你将见证定理在各种领域和行业中的深远影响。
最终,目标是为你提供一个既简洁又正式的全面解释。不需要高级数学的先验知识,虽然一些图论和离散数学(逻辑和集合论)的知识会有很大帮助;你只需要一颗好奇的心和解开这个杰出定理实用性的愿望。
历史
Maxflow Mincut 定理首次由 Ford 和 Fulkerson 在 1956 年的开创性论文“Maximal flow through a network”中提出,并与其他相关数学家,如 Claude Shannon,即信息论的发展者合作。该定理指出,网络中的最大流量等于一个割的最小容量,其中割是将网络节点分成两个不相交的集合,其容量是穿过割的所有边的容量之和。从那时起,这一定理成为流网络理论的基石。
然而,这个定理的引入伴随着其他关键的科学贡献,例如 Edmonds–Karp、 Ford–Fulkerson 或 Dinic’s 算法,这些算法都用于寻找可以通过源和汇之间的网络传递的最大流量。同样,通过最大流最小割定理,这个值与将汇与源分开的最小割相匹配。此外,我们可以利用算法的内部计算来识别构成最小割的边集,正如我们将进一步探讨的那样。
流网络
因此,为了简化后续定理的解释,我们首先将了解图论中流网络的基本原理和不可错过的概念。

示例流网络(作者提供的图像)
如上例所示,流网络是一个加权的、有向的多重图,用于表示一个网络结构的对象或系统,其中一定量的资源,以所谓的“流量”来衡量,需要从一个或多个点 “源”(表示为 S 节点) 传输或移动到一个或多个其他节点,称为 “汇” (T 节点)。尽管这个特定示例没有显示多重图的特性,因为两个节点之间只有一条边。
为了实现这样的模板表示,流网络的每条边都有权重。在这种情况下,加权边建模了多个资源(流量)交换点之间的物理/逻辑连接,其中实际的正值权重代表其 容量(最大流量支持)。如上所示,容量标记在每条边标签的右侧,以及通过的当前流量,这里为 0。
除了每条边的容量外,定义资源每单位时间穿越每条边的速率的关键指标是 流量。你可以把它想象成道路上的交通或管道中的水量。因此,由源节点或 超源 节点生成(如果所有网络的源节点连接到主源流量生成器),并传递到汇节点或 超汇(如果汇节点上有类似构造),我们可以将流量定义为一个函数 f:E→R ,它接受属于图边集 E 的边 (u,v) 并输出其当前流量 f(u,v),这是一个实际的正值。

作者提供的图像
因此,如果我们计算流网络中所有对应的源S或汇T的上述表达式,我们可以得到通过图的总流量。
为了保证流量符合网络的约束,它必须满足两个基本属性:
-
容量约束:通过任何边的流量不能超过其容量。正式地,如果边的容量表示为“c(u, v)”,而通过该边的流量为“f(u, v)”,那么它必须满足条件0 ≤ f(u, v) ≤ c(u, v),适用于网络中的所有边(u, v)。简单来说,我们不能通过一条边推动超过其容量所设定的流量。
-
流量守恒:在每个节点(不包括源节点和汇节点),进入节点的总流量必须等于流出的总流量。

作者图片
这确保了流量持续流动,不会在网络内积累或消散,尽管你可以在系统需要时允许流量积累。在数学上,对于每个节点“u”及其邻接节点,由超节点“v 和 w”表示和聚类,流量守恒属性表示为:

流量守恒属性(作者图片)
最后,请注意流量可以相互抵消,因为如果流量f1(u,v) 和 f2(v,u) 在两个节点u和v之间共存,那么减少f1(u,v) 相当于增加f2(v,u),因为它们具有相反的方向。
剩余网络和增广路径
在这里,我们将引入两个新的、更复杂的概念,这些概念在使用之前提到的算法找到最大流量时将非常有帮助。
这些中的第一个是边的容量和在给定时间的流量之间的差异,称为剩余容量,并表示为:

作者图片
牢记这个属性,我们可以定义一种特殊类型的流网络,称为剩余网络,它与标准网络的唯一区别在于其边的重新定义容量。剩余网络具有上述定义的函数cf,该函数将边的集合及其相应的容量和流量映射到相应的剩余容量。

流量-剩余网络示例(作者图片)
在这个示例中,你有一个网络,其中上图中所有边都有特定的流量函数。因此,剩余网络是下图,其边标签包含可以根据相应边的方向发送的剩余容量,以及在撤销流量增广操作时可以交付的反向流量的数量(记住流量抵消属性,这在网络具有某些对称性时可能有用)。
在这里,通过网络实现的流量可以用之前看到的源或汇公式计算,在这种情况下,是4+3个单位从S发出,或4+1+2个单位到达T。然而,如果我们考虑边(v5, v1)的反向方向(或双向),有可能沿着路径S-V1-V5-V4-V3-T发送 2 个更多的流量单位,这将增加总流量,并成为给定网络中最大的可用流量。随后,在得出残余网络后,可能在一个或多个连接源与汇的路径中,所有边的残余容量都大于 0。换句话说,有路径可以从源传输流量到汇,在有多个源或汇的情况下。

增广路径示意图 (作者提供的图片)
在这个背景下,这些路径是解决流量最大化或成本最小化问题的算法的基础,被称为增广路径。要理解为什么,在上面的网络中,我们可以看到建立的流量导致了一个增广路径的存在,其中 2 个单位的流量可以从S传输到T。因此,网络上的实际流量函数并未提供通过它的最大可运输流量,这也是我们稍后将讨论的最大流最小割定理面临的问题之一。

作者提供的图片
因此,如果我们增加通过显示路径的流量,我们将能够确保最终的流量达到最大值 9 个单位,因为不会有其他路径来增加网络流量。最后,在引入定理之前,重要的是要记住,要找到网络的最大流量,诸如Ford-Fulkerson这样的算法使用一种直观的过程,贪心算法从一个完全没有流量的残余网络开始,并通过这些路径来增加流量,(通过残余边或相反方向的流量的帮助)。因此,一旦没有更多的路径可以发现来增加流量,就可以确保流量达到了最大值,即由于一些边的容量不足或网络中甚至没有边,从S到T没有更快的方法来移动资源。
另一种思考这种过程的方法是考虑每次迭代中增加的流量。对于任意的增广路径,你可以增加的最大流量由最小剩余容量的边决定,因为它构成了从S流出的瓶颈。由于它能够限制沿增广路径的所有潜在流量,这种瓶颈边在需要最大化流量的情况下至关重要。

瓶颈边 (作者图片)
例如,考虑上面简单的流网络(剩余的),只有一条增广路径可用,我们可以清楚地识别出边(v2, v3), 的瓶颈组件,它将整个路径(以及在这个情况下的网络)的最大流量设置为 3。按照路径的建议将流量增加 3 个单位后,没有增广路径可以进一步增加流量,因此最大流量被认为已达到。然而,验证结果流量的另一种方法是关注网络中的瓶颈;如果每条 S 和 T 之间的路径都有一个为零的瓶颈值,即其最大剩余容量为 0,这等同于没有增广路径,则不能再添加更多流量,当前流量将被认为是最大流量。
为了解决瓶颈问题;我们应该强调,最大流量也可以表示为所有增广路径瓶颈的总和,这些瓶颈用于通过类似于 Ford-Fulkerson 的算法找到最大流量,因为每条路径的流量增加量由其对应的瓶颈决定。
流网络切割
我们将讨论的流网络基础的最后一部分是切割,它是最大流最小割定理的一个基本组成部分,并且是理解前面章节的一个关键概念,比如瓶颈与流网络分区之间的关系。
首先,我们从它的定义开始;一个切割是将网络节点分成两个集合,其中源节点 S 在集合A中,汇节点 T 在另一个与之不相交的集合B中。

作者图片
集合A和B都不能是空的,因为它们必须分别包含源节点和汇节点。因此,如果网络是连通的,则会有边在 A 和 B 之间双向连接这些节点。此外,这些边包含在另一个名为切割集的集合中,但只有那些起点在 A 中的节点并且终点在 B 中的边被考虑,即能在正确方向上运输流量的边。

作者图片
例如,上面可以看到我们可以应用于图的最简单的切割, resulting in a partition of the vertex set V as the union of the sets A={S} and B={V1,V2,V3,V4,V5,T}。由于唯一的网络源保持在一个集合中隔离,我们可以更准确地理解切割的概念及其后续属性。通常,切割被表示为一条线,旨在包围两个集合中的一个 A 或 B,不分区别。此外,分界线穿越了多个属于cut-set的边,这些边用于确定切割的流量和容量,它们在建立网络的任意切割和流量之间的关系时是至关重要的。这对于证明本文中提出的定理至关重要。
一方面,通过任何给定切割的流量定义为所有承载 A 到 B 方向的流量的边的总和,减去从 B 到 A 方向的边的流量。

图片来自作者
然而,流量在这种情况下并没有显著的价值,因为它受到切割容量的限制。因此,我们也可以以类似的方式定义切割的容量,区别在于这里只考虑上述公式的第一项,即能从 S 到 T 传递流量的边的容量,而不需要减去其他边的值。

图片来自作者
一旦我们正式介绍了流量和切割容量的概念,就有必要考虑一些示例,以尽可能简化这些概念并理解它们在这一领域的原理。

图片来自作者
首先,让我们检查每个集合的顶点,留下A={S,V5,V4}和B={V1,V2,V3,T}。由于网络已经分配了流量,因此切割流量不会为零,将由连接集合 A 到 B 的边上的流量总和决定。

图片来自作者
此外,它的容量来自相同的边及其各自的容量,构成了可能通过切割的最大流量。

图片来自作者

(A={S,V2}, B={V1,V5,V3,V4,T})切割 (图片来自作者)
在这个有趣的最后示例切割中,我们可以观察到切割不一定是一个分裂,其中两个集合的顶点组成了连接的组件,也就是说,只要满足切割的基本约束,每个集合可以包含任何节点。

图片来自作者
此外,这个例子特别有助于理解切割与流量之间的关系,为解决定理提供了坚实的基础。首先,请注意根据切割定义,结果网络(切割后)在 s-t 方面是断开的。这意味着这样的切割的容量是从源头汇总到汇点的所有边的总和。在隔离单一流源的最基本情况下,切割的容量将超过或等于网络的最大流量。然而,在之前的示例中,可以看到通过插入更多具有外发边的节点,切割的容量不可避免地增加,因为有更多的边而不是严格所需的以达到最大流量,即源头的边决定了在瓶颈情况下随后的网络流量。
定理陈述
我们在解决流网络优化问题时的主要目标是确定从源头到汇点的最大可达流量。这必须在遵守容量限制、流量守恒的前提下完成,并确保达到的流量实际上是最大值。因此,我们在处理定理时的步骤将是用一个可以大致类似于流量的上界来限制该值,从而确认其正确性。
首先,需要强调的是,这样的上界实际上是一个切割,满足具有最小容量的特性。作为定理的主要引理,它可能并不完全清楚,所以让我们引入并证明两个更简单的概念;

作者图片
第一个步骤涉及证明任何给定的切割处的流量与整个网络流量之间的上述等式,这反过来又与源生成的流量相匹配。为此,我们可以假设初始命题为真,同时对任何切割的集合 A 应用归纳法,其中 A={S}作为基本情况,然后使用前面提到的流量守恒原则,针对不同于 S 或 T 的节点。但由于这会比较复杂,我们将选择一种更简单但非常相似的方法。
请注意,在证明过程中,之前的流量值可以是任何允许的值。
1- 流量定义: 在初始步骤中,我们从网络中任何给定流函数f的总流量值开始,并定义其可能的定义之一。在这里,以源节点 S 为参考,即任何网络切割的最小可能集合,我们将流量值与由 S 生成的流量相匹配,减去流入 S 的流量,因为有时可能会有一定量的流量返回到 S。

作者图片
2- 流量守恒属性: 在考虑网络流量为源点 S 产生的总流量后,我们应用流量守恒原理,即除了 s-t 外,所有节点必须传播它们接收到的所有流量,从而使流量 |f| 通过减去流出流量与流入流量的差值来贡献为零。现在,如果我们考虑任何切割 (A,B),节点 v 在集合 A 中除产生流量的节点 {S} 外,总流量将为零,满足我们之前的等式。

图片由作者提供
3- 流量通过切割: 最后,我们得到一个表达式,其中在第二项中将集合 A 中除 S 外的所有节点的流出流量相加,并将 S 自身的流出流量在第一项中,减去所有之前节点的对应流入部分。这对应于前面提到的切割流量定义,因此我们可以得出结论,网络中的所有现有流量必然与任何给定切割的流量匹配。

图片由作者提供
我们将证明的关于最大流最小割定理的第二个命题包括一个不等式,该不等式为网络中任何流量的值提供了一个上界,限制为任何给定切割的容量值。

图片由作者提供
1- 替代流量定义: 利用关于任何切割的流量的先前结果,我们可以将任意流量 |f| 等同于通过任意切割 (A,B) 的流量。
2/3- 流量边界: 在第二步中,我们建立了一个不包含模拟集合 A 中流入流量的第二项的不等式,只保留从 A 到 B 的边的流出流量。移除这样的项后,结果将始终大于或等于之前的结果,因为如果没有边从 B 返回到 A,则剩余边从 A 到 B 的流量总和不会减少。
然后,我们可以通过设置从 A 流出的边的流量小于或等于这些边的容量来简单地增加不等式的值。这一不等式的有效性由所有网络边上出现的容量约束所给出。
4- 弱对偶性: 在将集合 A 的所有流出边的容量总和与由于其定义的切割容量匹配后,可以得出结论,对于网络中的任何给定流量和切割,流量将始终小于或等于切割容量,这也成为我们即将证明的定理的起点。此外,如果我们试图最大化流量,我们将达到一个点,这可以通过最小化切割容量来满足,建立了一种弱对偶关系,其中没有确定性保证最小容量切割总是等于最大流量。
在此阶段,在达成最大流最小割定理之前的弱对偶性之后,我们可以提供一个更易于理解和验证的声明。

作者提供的图像
如前所述,该定理通过 强对偶性 得以成立,即任何网络中的最大流量与可达的最小容量割匹配。与前述的弱对偶结果不同,该定理确保流量的 最大化 对偶与任何割容量的 最小化 完全相等,消除了两者结果之间存在差异的可能性,并且在引理上提供了强对偶条件。

(A={S,V1}, B={V2,V5,V3,V4,T}) 割 (作者提供的图像)
在进行演示之前,我们应该强调定理的一个使用案例。在这里,最大流的值为 7,等于每个外流割边的容量之和。请注意,这些边承载了最大容量的流量,在像所示的最小容量割中,这些边成为瓶颈,即割集本身作为全球网络流的瓶颈。为了简化对这个想法的解释,您可以在下面找到一个资源以帮助您理解:
我即将阅读最大流最小割定理的证明,该定理有助于解决最大网络流问题。可以…
证明
如果我们想证明最大网络流在所有情况下等于网络中的最小容量割,我们将使用 3 个必须等价的命题,以确保定理的正确性。
存在一个割 (A, B) 满足 |f|= cap(A, B)。
流量值 |f| 是最大值。
流网络中不存在增广路径。
为了显示所有陈述是等价的,我们将演示逻辑推导 1⇒2⇒3⇒1。意思是我们可以从任何陈述推导出其他任何陈述。在 1⇒2 的情况下,可以使用之前展示的弱对偶性轻松验证。然后,考虑到任何流量都小于最小容量割,如果我们假设存在一个等于任意割容量的流(1),弱对偶性告诉我们,这个容量是任何给定流量的上界,因此结果流量与该上界相符,是最大流量(2)。
进行 2⇒3 的验证,最简单的方法是取对立命题 ¬3⇒¬2。然后,举例一个任意流 |f|,如果存在一个可以输送流量的增广路径 s-t ¬(3),则可以通过相应路径增加 |f|,这意味着 |f| 原本不是最大流量 ¬(2)。
最具挑战性的步骤是 3⇒1。首先,我们假设网络中没有增广路径的流 |f|。此外,我们定义一个集合 A,包含在残余网络中从 S 可达的所有顶点。也就是说,A 包含所有在残余网络中存在从 S 到达路径的顶点,同时该路径的所有残余边都不为零。通过这些定义,我们可以确定 S 在 A 中,因为它是自我可达的,而由于没有增广路径,T 在残余网络中从 S 无法到达,因此我们知道至少有一个节点 (T) 不在集合 A 中。然后,如果我们将 T 插入到一个不同的集合 B 中,那么 (A, B) 这一对满足作为网络中有效割集的所有标准。

示例 (A={S,V1,V4}, B={T,V2,V3}) 割 (图片由作者提供)
在这一点上,我们必须意识到关于割 (A, B) 的两件事。一方面,S-T 方向上的割流必须等于其容量。因为根据之前的定义和假设(3),它们不相等的唯一可能性在于 B 的节点的可达性,因此如果它们中的任何一个从 S 在残余网络中可达,导致割边的流量未达到其最大容量,则该节点必须在 A 中而不是 B 中,这就产生了矛盾。另一方面,割的另一个方向的流量因同样的原因为零,即如果它不是零,那么在残余网络中就会有一条 A-B 方向的边 (残余边流量以负号表示) 到达 B 节点,造成矛盾。

图片由作者提供
最后,剩下的唯一任务是将网络流与之前演示的割流匹配,去掉流量中的割流项(因为它为零),并使用割容量定义来得出流 |f| 等于得到的割容量(3⇒1)。
应用
最大流最小割定理在各个领域有着广泛的应用。然而,为了简洁起见,我们将简要提及一些关键方面,并提供更多详细资源,以帮助你正确理解这些应用。
Ford-Fulkerson/Edmonds–Karp 算法
作为第一个后果,定理提供的发现和结果,加上其他定理如整性定理,导出了并支持了一系列旨在计算最大流的算法的正确性证明。
其中最重要的,也是我们已经讨论过的,是Ford Fulkerson’s算法,这是一种贪婪方法,通过寻找 s-t 增广路径来增加流量。然而,算法的最基本版本在某些特定输入下(例如处理实际或无理数及其表示)没有终止或收敛到最大流的保证,这影响了它的时间复杂度,时间复杂度为O(|E| |f|),这意味着在最坏的情况下,算法需要遍历网络中所有边缘,以达到最大流中的每个(至少一个)流量单位。
然后,为了改进之前的版本,即最早创建的解决此类问题的版本,改进了计算增广路径的方法。这样,虽然Ford-Fulkerson版本使用深度优先搜索(DFS),计算随机路径到 T,而改进后的Edmonds-Karp变体使用广度优先搜索(BFS)算法来找到增广路径。因此,旨在每次迭代中选择边数最少的增广路径,这个算法相较于之前的版本具有终止保证,并且时间复杂度改变为O(V E²)。
然而,使用这些及类似的算法,不仅可以计算网络中的最大流,还可以计算其容量等于值的最小割。过程非常简单;在计算网络中所有边的最大流之后,根据最大流最小割定理,从 S 出发可以访问的节点在相应的剩余网络中形成我们寻找的割的集合 A,剩余的节点形成 B,从而得到结果最小容量割(A, B)。
最后,需要指出的是,最大流算法的研究领域远大于此处所展示的内容。因此,如果你希望继续学习,这里有一个资源详细介绍了这些算法及其实现。
实际应用案例
我们生活中几乎所有的系统都有可能被建模为流网络(至少部分),这使它们成为解决复杂可扩展性问题的重要工具。由于可能性很广,这里仅提及一些与基本概念直接相关的应用。
最初,所有的交通系统,从道路网络和公共交通系统到航空路线和货物分配,都可以表示为流网络。因此,我们可以分析交通模式、优化路线,并提高整体效率。这在城市规划中尤为重要,因为管理人员、车辆和货物的流动对于防止拥堵和确保顺畅运营至关重要。此外,并非所有这些用例都是完全有益的;例如,流网络也可以模拟一个国家的铁路系统,在军事冲突中可能成为攻击的目标,这些攻击应尽可能具备战略最优化。你可以在这个资源中了解更多关于这一特定应用的信息。
尽管在电信、能源分配甚至医疗保健中有其他超越的实现,我们将重点关注一个与计算机科学更密切相关的领域,特别是计算机视觉,该领域已取得了显著突破。在图像处理领域,流网络的主要应用依赖于图像分割算法,该算法负责将图像划分为对应于对象、主题或特定区域的区域,这些区域可能无法通过肉眼区分。在这种情况下,流网络通过将像素之间的关系建模为网络而展示其优势,其中边表示邻近像素之间相似性/差异性的可能值流动。此外,还值得提及在类似范围内的应用,如机器学习模型,其中流的概念用于优化特定的学习、生成或分类任务。
结论
本文涵盖了流网络数学领域的一小部分,并证明和简化了其中一个基本定理。然而,由于这是一个具有广泛应用的主题,特别是在世界消费、交通和人口管理系统中,因此继续扩展理论并深入了解这些应用是有益的。为此,观察定理更高级形式化以及逐步理解本文提到的算法和学习有关流网络某些应用的新概念的最有效资源是以下内容:
www.cs.upc.edu/~mjserna/docencia/grauA/P20/MaxFlow-fib.pdf
ocw.tudelft.nl/wp-content/uploads/Algoritmiek_Introduction_to_Network_Flow.pdf
在强化学习中离散化连续特征
原文:
towardsdatascience.com/discretizing-the-continues-features-in-reinforcement-learning-b69b388215ea
如何使用平铺编码和 Python 将无限变量转换为离散空间
·发表于 Towards Data Science ·阅读时间 6 分钟·2023 年 3 月 13 日
--

图片由 Ehud Neuhaus 提供,来源于 Unsplash
本文是强化学习系列的延续。要回顾,请访问以下文章:
Python 的原始实现,展示如何在强化学习的基本世界之一中找到最佳位置…
towardsdatascience.com ## 使用 Python 的时间差 — 第一个基于样本的强化学习算法
使用 Python 编码并理解 TD(0) 算法
towardsdatascience.com ## 使用 Q-learning 的强化学习中行动的价值
从零开始在 Python 中实现 Q-learning 算法
towardsdatascience.com
关于 Q 学习的最后一篇文章探讨了将数字分配给状态动作对的概念:

Q 值函数
使用的状态是可以列出并写入表格的状态。例如,我们对代理可能处于的迷宫中的所有可用位置进行了索引。即使在一个巨大的迷宫(想象一个百万乘百万的网格)中,我们仍然可以为每个状态分配一个唯一的索引,并在填写 Q 表时直接使用这些状态。
实践中,我们的代理所处的状态通常不能被唯一索引并适配到表格中。例如,假设状态是轮子的角度,该角度可以精确地旋转一次,并且可以取[-360, 360]度范围内的任何值。轮子可以精确地转到12.155…、152.1568…等度数。我们不能索引所有独特的度数并创建一个表——可能性是无限的。
然而,我们仍然希望使用强化学习(RL)提供的所有算法。因此,第一步是从具有无限可能性的特征中创建一个离散特征空间。
离散化连续特征空间的流行技术之一是所谓的瓷砖编码算法。
瓷砖编码的定义如下¹:
瓷砖编码是一种通过将状态空间划分为若干重叠区域(称为瓷砖),然后通过状态所落入的瓷砖集合来表示连续状态空间的方法。
我们可以用以下代码和图表示一个简单的 1 特征离散化:
# Creating an example 1D feature that goes from 0 to 1
x = np.linspace(0, 1, 100)
# Defining the number of tilings
n_tilings = 4
# Defining the offset
offset = 0.05
# Defining the number of tiles in a tiling
n_tiles = 10
# Creating a list of tilings
tilings = []
cur_tiling = 0
for i in range(n_tilings):
# Creating a tiling by adding the offset to the feature
tiling = x + cur_tiling * offset
# Appending the tiling to the list
tilings.append(tiling)
# Incrementing the tiling
cur_tiling += 1
# Ploting the x feature and the tilings
# The x feature is plotted a horizontal line
# The tilings are plotted as horizontal lines, each moved up by 0.1
vertical_offset = 0.1
plt.figure(figsize=(10, 5))
plt.plot(x, np.zeros_like(x), color='black')
for i, tiling in enumerate(tilings):
plt.plot(tiling, np.zeros_like(x) + vertical_offset + vertical_offset * i, color='red')
# Adding vertical ticks on the tiling lines
for j in range(n_tiles):
plt.plot(
[j / n_tiles + offset * i, j / n_tiles + offset * i],
[vertical_offset + vertical_offset * i - 0.01, vertical_offset + vertical_offset * i + 0.01],
color='black'
)
plt.xlabel('Feature values')
plt.ylabel('Tilings')
# Drawing a vertical line at x = 0.46
plt.plot([0.46, 0.46], [0, vertical_offset * n_tilings + 0.1], color='blue', linestyle='dashed')
plt.show()

x=0.46 的瓷砖编码实际操作;图由作者提供
为了理解瓷砖编码,我们需要完美地理解上述图中的内容。
最底部的水平线是特征x,它可以在[0, 1]范围内取得任何值。
每条红线是用于离散化特征x的瓷砖。
每个瓷砖被划分为瓷砖,这些瓷砖间隔均匀。
蓝色虚线是从 x 范围中取出的随机值。问题是,我们如何使用 4 个瓷砖和 8 个瓷砖来从 x 特征值创建一个离散状态?
算法如下:
给定来自连续 x 特征的值 s:
对于每个瓷砖:
-
初始化一个大小等于瓷砖数量的向量。将其填充为 0。
-
计算s 值落在哪个瓷砖中。保存该索引i。
-
用值 1 填充向量坐标i。
最后,将所有向量堆叠成一个向量。
让我们计算图中展示的示例。对于第一个瓷砖,在特征空间 x 的正上方,蓝色值落在第 5 个瓷砖空间中。因此,第一个瓷砖的特征向量是:
[0, 0, 0, 0, 1, 0, 0, 0]
对于第二个瓷砖,我们重复相同的过程,并得到如下向量:
[0, 0, 0, 0, 1, 0, 0, 0]
第三和第四个瓷砖向量:
[0, 0, 0, 1, 0, 0, 0, 0]
[0, 0, 0, 1, 0, 0, 0, 0]
表示蓝色虚线“状态”的最终离散向量是
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0]
让我们再做一个例子,x 值为 0.44,以完全理解这个过程。

x=0.44;作者图表
每个平铺向量(从底部开始):
[0, 0, 0, 0, 1, 0, 0, 0]
[0, 0, 0, 1, 0, 0, 0, 0]
[0, 0, 0, 1, 0, 0, 0, 0]
[0, 0, 1, 0, 0, 0, 0, 0]
最终状态向量:
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]
最终状态向量的长度为 N 平铺 * N 瓦片。
将向量分配给由 2 个特征表示的状态的过程遵循一个非常类似的算法。现在的平铺不再是水平线,而是矩形。
让我们假设我们的状态由连续的 x 和 y 变量组成,每个变量的范围从 0 到 1。
我们将用 2 个平铺将整个特征空间划分,每个平铺包含 4 个瓦片:

二维连续空间;作者图表
上图中的灰色区域表示原始特征空间。每个红色平铺被划分为 4 个瓦片。我们想为蓝点(0.44,0.44)创建一个表示状态的向量。
算法与 1D 情况相同,但现在我们为点分配索引,按从左到右的顺序,从左上角开始:

索引平铺;作者图表
因此,对于第一个和第二个平铺,蓝点将落入第 3 个平铺,结果状态向量如下:
[0, 0, 1, 0]
[0, 0, 1, 0]
最终向量将是:
[0, 0, 1, 0, 0, 0, 1, 0]
取另一个点:

另一个二维点;作者图表
向量将是:
[1, 0, 0, 0]
[0, 0, 1, 0]
最终向量为:
[0, 0, 1, 0, 0, 0, 1, 0]
创建表示 3D 及以上维度状态的向量的直觉与上述示例相同。
在 Python 中可以找到一个强大的实现:
RLAI 打开网页在这里我们描述了实现平铺编码核心部分的软件,如...
在本文中,我展示了如何从连续特征空间到有限向量以表示状态,使用平铺编码。在本 RL 系列的下一篇文章中,我将使用这种编码为每个状态分配动作值。
快乐学习,敬请关注!
[1]
作者: 理查德·萨顿
揭示真实数据离散度的两个指标超越标准差
数据科学
变异系数和分位数离散度系数计算与解释指南
·发表于 Towards Data Science ·8 分钟阅读·2023 年 7 月 30 日
--

图像由作者使用 StockImg.AI 生成
介绍
我们都听过这样一句话:“变化是生活的调味品”,在数据中,这种变化或多样性通常表现为离散度。
数据的离散度使数据变得引人入胜,因为它突出了我们否则无法发现的模式和见解。通常,我们使用以下作为离散度的度量:方差、标准差、范围和四分位距 (IQR)。然而,在某些情况下,我们可能需要超越这些典型的度量来检查数据集的离散度。
在比较数据集时,变异系数 (CV) 和四分位离散度系数 (QCD) 提供了洞察力。
在本教程中,我们将深入探讨 CV 和 QCD 两个概念,并回答它们各自的以下问题:
-
它们是什么,它们是如何定义的?
-
如何计算它们?
-
如何解释结果?
上述所有问题将通过两个示例进行详细解答。
理解变异性和离散度
无论我们是在测量人的身高还是房价,我们很少发现所有数据点都是相同的。我们不会期望每个人都一样。有些人高,有些人中等,有些人矮。数据通常会有所变化。为了研究这种数据变异性或离散度,我们通常使用范围、方差、标准差等度量来量化它。离散度的度量量化了我们数据点的分布情况。
但是,如果我们希望评估不同数据集之间的变异性呢?例如,我们如果要比较一家珠宝店和一家书店的销售价格怎么办?标准差在这里无效,因为这两个数据集的尺度可能非常不同。
在这种情况下,CV 和 QCD 是有用的离散度指标。
深入探讨:变异系数
变异系数 (CV),也被称为相对标准差,是一个标准化的离散度量。它以百分比形式表示,没有单位。因此,CV 是比较不同尺度数据的出色离散度量。
从数学角度来看,CV 计算为标准差与均值的比率,有时乘以 100 得到百分比。公式如下:

让我们使用 Numpy 的 mean 和 std 函数在 Python 中计算 CV。
def calc_cv(data_array) -> float:
"""Calculate coefficient of variation."""
return np.std(data_array) / np.mean(data_array)
现在,让我们看看如何在一个例子中使用这个统计数据。
示例 1
考虑以下两个数据集,展示了珠宝店和书店的月销售额:
-
珠宝店:月平均销售额为 $10,000,标准差为 $2,000。
-
书店:月平均销售额为 $1,000,标准差为 $200。
让我们使用 Numpy 生成两个例子的样本数据。
Jewelry Shop:
- Mean = $10119.616
- Standard Deviation = $2015.764
- CV = 0.199 (dimensionless)
Bookstore:
- Mean = $1016.403
- Standard Deviation = $206.933
- CV = 0.204 (dimensionless)

通过标准差研究珠宝店和书店的月平均销售额的离散度。生成这个图表的代码可以在笔记本中找到。(图像作者)
珠宝店的平均销售额和标准差显著高于书店(平均 $10,119 和标准差 $2,015 对比书店的平均 $1,016 和标准差 $206),然而它们的 CV 相同(20%)。
这意味着,相对于各自的平均销售额,尽管销售量(及其标准差)有很大差异,珠宝店和书店的相对变异性是相同的。
这展示了 CV 作为相对变异度量的概念,并展示了它如何用于比较不同尺度的数据集。
接下来,让我们考虑另一个无量纲的离散度量,即 QCD。
深入了解:四分位离散系数
四分位离散系数 (QCD) 是另一种相对离散度量,特别适用于处理偏态数据或具有离群值的数据。QCD 关注数据集中间 50% 部分的分布,即四分位距 (IQR)。这就是为什么 QCD 是一个稳健的度量。
QCD 的计算方法如下:

其中 Q1 是第一个四分位数(第 25 百分位数),Q3 是第三个四分位数(第 75 百分位数)。
def calc_qcd(data_array) -> float:
"""Calculates Quartile Coefficient Difference"""
q1, q3 = np.percentile(data_array, [25, 75])
return (q3 - q1) / (q3 + q1)
类似于 CV,QCD 是一个无单位的度量,对比较偏态数据集的离散度可能非常有用。
以下示例将更好地展示 CV 和 QCD 背后的概念。
示例 2
考虑两个公司员工年龄的数据集。
-
公司 A(一个初创公司):年轻员工,有些年长员工。
-
公司 B(一个成熟的公司):年长的员工,一些较年轻的员工。
让我们使用 Numpy 为两个示例生成样本数据。
Company A:
- Q1 = 22.840 years
- Q3 = 26.490 years
- IQR = 3.650 years
- QCD = 0.074 (dimensionless)
Company B:
- Q1 = 42.351 years
- Q3 = 47.566 years
- IQR = 5.215 years
- QCD = 0.058 (dimensionless)
现在,让我们绘制数据分布及其箱线图和 QCD,以可视化上述信息。

基于 IQR 的稳健度量,研究公司 A 和公司 B 员工年龄的离散度。生成此图的代码可在 notebook 中找到。(图片由作者提供)
公司 B 的 IQR(5.215 年对比 3.65 年)表明年龄离散度更广。然而,公司 B 的老年员工影响了这一点(查看箱线图)。
另一方面,公司 A 的 QCD(0.074 对比 0.058)比公司 B 更大,显示出相对于其中位数,年龄分布的变异性更大。IQR 并未揭示这一点。
在接下来的部分,我们将学习如何使用 CV 和 QCD 量化这种差异。
讨论
让我们回答一些你可能会有的问题。
为什么不关注标准差或 IQR 这样的度量?
我们使用标准差和 IQR 来量化数据集的离散度。标准差显示数据点与均值之间的平均距离。IQR 显示我们数据中间 50% 的分布情况。
然而,在比较具有不同单位或规模、偏态分布或存在离群值的两个或多个数据集的离散度时,这些度量可能会产生误导。
虽然标准差和 IQR 是有用的统计工具,但我们偶尔需要 CV 和 QCD 来进行公平比较。
CV 和 QCD 都衡量和比较变异性,尽管它们的方式有所不同。你的数据和期望的变异性决定了使用哪一个。
何时使用 CV
CV 是比较具有不同大小、单位或平均值的数据集的变异量的好方法。由于 CV 是一个相对的分散度量,它显示了事物与均值的不同程度。
平均值和标准差这两个受“离群值”影响较大的度量被用来创建 CV。因此,CV 可能会对那些不符合正态分布或具有离群值的数据集提供扭曲的分散视图。因此,CV 最适合用于均匀分布且没有极端值的数据。
在销售案例中,两个组的价格范围差异很大,因此用于测量它们销售的尺度也非常不同。珠宝店的平均销售额可能要高得多,并且变异性也大得多。如果我们使用标准差来衡量这两个组的变异性,可能会得出错误的结论,认为珠宝店的销售变异性更大。
CV 允许我们比较两个数据集之间的销售变异性,而不管它们的不同规模。如果某一类别的 CV 较高,这意味着该类别的销售相对于平均销售而言变异性更大。
何时使用 QCD
QCD 使用数据集四分位数,这些四分位数对异常值的敏感度较低。QCD 是对偏斜分布或包含异常值的数据集的鲁棒离散度测量。QCD 关注数据中心 50%,这可能更好地捕捉这些数据集的离散度。
在我们的例子中,我们检查了两家公司之间的年龄差异:一家以年轻员工为主的初创公司(A)和一家以年长员工为主的成熟公司(B)。鉴于它们的年龄范围不同,年长公司的中位年龄和变异性会更高。使用四分位数范围(IQR)来比较离散度可能不准确地表示成熟公司的年龄变异,因为 IQR 测量的是绝对变异,并且对于较大值更高。
QCD 更有效,因为它以中位数为基准标准化变异性,使我们能够在不同尺度下比较公司之间的年龄变异性。较高的 QCD 表示相对于中位数的年龄变异性更大。因此,选择 QCD 进行比较是因为它考虑了不同的尺度以及潜在的数据偏斜或异常值。
收获
选择 CV 还是 QCD 取决于数据集的性质和分析目标。以下是关于这两种度量的关键点:
变异系数(CV):
-
CV 是通过标准差与均值的比率计算得出的。
-
CV 是无量纲的。
-
较高的 CV 表示相对于均值的变异性更大。
-
如果均值接近零,CV 可能会产生误导性结果(除以零!)。
四分位数离散系数(QCD):
-
QCD 基于四分位数。
-
QCD 是一个鲁棒的测量指标(对极端值不敏感)。
-
QCD 是无量纲的。
-
较高的 QCD 表示相对于中位数的值变异性更高。
-
如果分布的尾部很重要,QCD 无法完全捕捉散布情况。
结论
总结一下,变异系数(CV)和四分位数离散系数(QCD)是检查数值数据离散度的重要统计指标。CV 在比较标准化数据方面表现出色,而 QCD 在偏斜或异常值数据集的情况下有帮助。我们通过两个案例(使用 Python 程序和分析)来观察这些指标在实践中的作用。通过明智地使用它们,我们可能会获得有用的决策信息。
📓 你可以在 GitHub 上找到本文的笔记本。
感谢阅读!📚
更新(2023 年 8 月 13 日): 本文的早期版本中在示例 2(QCD)中包含了两个相同直方图的错误图像。图像已更新以反映正确信息。
我是一个高级数据科学家 📊 和工程师,撰写关于统计学、机器学习、Python 等方面的内容。
-
在 Medium 上关注我 👋 以获取我的最新文章
有用的链接
变异系数(CV)是衡量数据点围绕均值离散程度的一种指标。
www.investopedia.com [## 四分位数离散系数 - 维基百科
来源于维基百科,自由百科全书。在统计学中,四分位数离散系数是一种描述性统计量…
维基百科 [## 变异系数 - 维基百科
在概率论和统计学中,变异系数(COV),也称为标准化均方根…
最初发布于 https://ealizadeh.com。
解剖 Twitter 顶级声音的覆盖范围和影响力
原文:
towardsdatascience.com/dissecting-the-reach-and-impact-of-twitters-top-voices-52262ef58b40
用数据科学绘制 Twitter 影响力的全景
深入探讨塑造 Twitter 最强大声音的关系和模式
·发表于 Towards Data Science ·阅读时间 16 分钟·2023 年 4 月 6 日
--

图片由作者生成,Midjourney 提示:“一幅描绘 2023 年 Twitter 状况的巴洛克风格杰作”
介绍
Twitter 因为一些大型账户的影响力而成为众多争议的中心。前 100 个 Twitter 账户(按关注人数排序)总关注量估计约为 41 亿。我们已经看到 Twitter 的顶级声音如何左右政治观点、影响金融市场、设定趋势,甚至煽动仇恨。作为一名数据科学家,我自然对通过深入分析他们的推文能揭示出什么样的模式感到好奇。
本文的其余部分将探讨我如何通过检查这些账户之间的关系来理解其影响力的本质。我将使用聚类算法和统计分析来揭示集群内部及跨集群的模式。我希望能更深入地了解这些顶级声音所具有的影响力的性质。
前 100 个 Twitter 账户 关注者数量的来源是 Social Blade(2023 年 3 月) 。
免责声明:此分析纯属探索性质,不应被视为定论。作者与提到的账户所有者没有任何关联,提供的见解也未得到他们的认可。
网络应用
我喜欢在项目中分享应用程序,以使读者的体验更加互动和参与。我将聚类和统计分析功能构建成一个网页,读者可以在其中进行实验。你可以调整聚类算法的超参数,并亲自观察这些调整对分析的影响。我建议在进行这些操作之前先阅读相关内容,以便熟悉方法。
💻账户 聚类应用 — 这个应用可以在手机上使用,但在电脑上查看效果最佳。
注意:由于库依赖问题,应用在 Streamlit 服务器上的编译效果与本地略有不同。应用中的聚类标签与博客文章中的显示有轻微不对齐,但聚类本身仍然相同。
数据
我使用了 Twitter API 从每个账户提取了最新的 100 条公开推文。由于 Twitter 数据比较混乱,我在进行分析之前进行了常规的预处理,去除了 URL。
转发被排除在分析之外,因此重点仅在于每个账户的原创推文。这确实使得一些账户的推文数量少于 100 条。由于 Twitter API 的局限性,这一问题较难解决。
请注意,这符合 Twitter 的服务条款。他们允许通过 其 API分析和聚合公开可用的数据。数据允许用于 非商业和商业用途.&text=Your%20product%20or%20service%20is%20monetized%20if%20you%20earn%20money%20from%20it.)。
目录
-
定义影响力:我如何定义和量化影响力。
-
维度减少与聚类:使用 UMAP 和 HDBSCAN 算法揭示数据中的隐藏结构。
-
统计分析:对聚类进行深入的统计分析。
-
观察结果:对聚类和统计分析结果的评论。
-
局限性与扩展:对这种方法的局限性进行简要讨论,并探讨如何进行扩展。
1. 定义影响力
第一步是明确影响力的定义。我将其概念化为两个主要类别,参与度和影响力。参与度估算了账户与其粉丝之间通过推文的互动情况。影响力估算了推文背后的情感和/或观点。
参与度指标
所有参与度指标都经过了调整,依据的是账户在分享推文时的粉丝数量,然后进行归一化处理。
-
收藏次数:推文被收藏的次数。
-
转发次数:推文被转发的次数。
-
引用次数:推文被引用的次数。
-
回复数:推文被回复的次数。
参与度指标
影响指标
影响指标通过对每条推文运行分类模型被细分为情感和情绪分布。之后,计算每个子类别在账户层面的分布。情感的子类别包括:积极、消极和中立。情绪的子类别包括:愤怒、快乐、乐观和悲伤。
用于在数据框中的推文上运行情感/情绪模型的模块。
请注意,模块 TextCleaner 是我创建的,用于从推文中删除 URL。
对每条推文进行情感和情绪分析,并在账户层面创建分布
情感和情绪检测的输出。每个 user_id 指代一个账户。这在聚类之前会与账户层面的指标表进行合并。

作者提供的图片:账户层面的情感和情绪分布
关于大型语言模型的说明
我使用了 Twitter-roBERTa-base 来进行情绪和情感分类。研究人员对约 6000 万条推文进行了模型预训练,并分别对情绪识别和情感分析进行了微调¹。训练语料库由 Twitter 自动标注的推文(英语)组成。研究人员已发布了模型在所有任务上的性能指标。

图片来自 Barbieri (2020)¹
2. 维度降低与聚类
维度降低和聚类的目的是揭示隐藏的结构,因此,基于定义的影响指标来探讨账户之间的关系。我将详细描述我如何做到这一点。
统计所有影响指标后,我们有 12 个维度需要聚类。在这样的高维空间中进行聚类可能会遮蔽模式(参见 维度诅咒),更不用说它是不可能进行可视化的。我通过使用 UMAP 将 12 个维度减少到仅两个来解决这个问题。
使用 UMAP 进行维度降低
在高层次上,UMAP 使用图布局算法将数据从更高维空间降低到较低维空间,同时尽可能保持结构相似性。
你可以将这视为在较低维度中保留高维空间中的‘信息’。
UMAP 不会完美地保留 12 个维度中的所有信息,但通过选择合适的超参数,它保留了足够的信息,给我们提供了数据结构的一些见解。选择合适的 UMAP 参数更像是一种艺术而非科学,我主要是调整参数,直到形成看似连贯的簇,考虑诸如样本量等因素。我将简要说明每个超参数及其对聚类的影响。
这是一个很酷的 资源 可以帮助你更好地理解 UMAP
-
n_neighbours:在构建高维图时,确定每个数据点所考虑的最近邻居的数量。它调整数据的局部和全局结构。较小的值优先考虑局部结构,结果是更详细的聚类,而较大的值则强调全局结构,导致聚类更连通且不那么明显。
-
min_distance:控制低维嵌入中数据点之间的最小距离(由 UMAP 算法构造)。它决定了点的聚集程度。较小的值生成更紧凑的聚类,保留局部结构。较大的值则将聚类扩展开来,使得可视化全局关系更容易,但可能会丢失一些细节。
-
距离度量:UMAP 算法使用距离度量来计算数据点之间的最小距离。由于 12 个维度是连续且归一化的(值范围在 0 到 1 之间),我选择使用欧氏距离。这一选择是合适的,因为欧氏距离测量两点之间的直线距离,有效地捕捉了归一化数据集中的关系。
使用 HDBSCAN 进行聚类
在降低度量空间的维度后,我能够应用聚类和随后的数据可视化。对于聚类,我利用了 HDBSCAN 算法,这是一种基于密度的聚类算法,可以有效地在二维空间中工作。
高层次上,HDBSCAN 根据密度转换数据点之间的空间,构建距离加权图的最小生成树,形成聚类层次结构,然后提取稳定的聚类。我使用 HDBSCAN 是因为它的稳健性和简洁性。只有一个(重要的)超参数需要调整——最小聚类大小。
HDBSCAN 库的创建者提供了详尽的 文档 供你进一步了解算法的内部工作原理。
我编写的生成聚类的模块:
用于执行 UMAP 降维和聚类的模块
运行聚类分析
在账户数据上运行聚类生成了六个稳定的聚类。我将在下一节中使用一些统计分析来研究这些聚类。

作者提供的图像:由聚类分析生成的账户聚类
你可以通过我建立的网页应用自己实验聚类分析。
3. 统计分析
我对各个类别进行了深入分析,以揭示隐藏的关系。所有统计分析都以 0.05 的显著性水平进行评估。
谁在这些类别中?
此时,我认为列出每个类别中的个体账户是有意义的。这应该为随后的分析提供一些背景。
第 0 类:政治类别—— 主要是政治家和世界领导人。令人惊讶的是,艾玛·沃特森和比尔·盖茨被包含在这个类别中……模型是否预测了他们未来职业志向的某些东西?两者都以政治活跃而闻名,因此这可能在他们的推文中有所体现。
5 Barack Obama
6 Joe Biden
48 Bill Gates
72 Emma Watson
87 PMO India
92 Hillary Clinton
94 Amit Shah
95 President Biden
第 1 类:新闻媒体(主要)—— 这主要是新闻媒体,但其中也包括一些名人。其中一些以其争议性的推文而闻名——这可能是为什么模型将他们与新闻媒体进行聚类的原因?
0 CNN Breaking News
1 BBC News (World)
2 CNN
3 Twitter
4 The New York Times
7 Reuters
9 BBC Breaking News
10 The Economist
19 National Geographic
26 Wiz Khalifa
33 Kourtney Kardashian
34 Donald J. Trump
45 Nicki Minaj
46 Elon Musk
62 Conan O'Brien
91 Cardi B
第 2 类:主要是体育组织—— 有趣的是,相当多的与体育相关的账户被包含在这个类别中:我认为除了两个,其他的都在这里。然而,难以将第 2 类仅仅归为体育,因为这里还有大量其他类型的账户。
注:指的是团队和组织,而不是运动员本身。
8 ESPN
11 YouTube
12 PlayStation
13 NASA
15 Real Madrid C.F.
24 NFL
25 NBA
36 SportsCenter
38 Drizzy
39 Justin Bieber
43 SpaceX
56 FC Barcelona
73 BIGHIT MUSIC
77 Adele
79 Whindersson
80 netflixbrasil
82 Miley Cyrus
90 UEFA Champions League
93 BTS_official
第 3 类:流行歌星和脱口秀主持人—— 第 3 类主要是流行歌星,但也包括一些电视名人,如奥普拉·温弗瑞和艾伦·德詹尼丝等。可以将这个类别大致归为娱乐类。这里只有一个组织,Instagram,因此我们可以非正式地将其归类为“明星”类别。
16 Jimmy Fallon
17 Ellen DeGeneres
20 Taylor Swift
21 PRIYANKA
23 Oprah Winfrey
28 Demi Lovato
29 KATY PERRY
30 LeBron James
32 Selena Gomez
37 Justin Timberlake
42 Khloé
47 Shakira
51 Rihanna
57 Bruno Mars
58 Shah Rukh Khan
61 Hrithik Roshan
63 Lil Wayne WEEZY F
69 Kendall
70 Liam
71 Neymar Jr
74 zayn
75 Instagram
78 One Direction
85 Shawn Mendes
第 4 类:运动员—— 第 4 类主要是运动员,主要是足球运动员和板球运动员。除了谷歌、布兰妮·斯皮尔斯和尼尔·帕特里克·哈里斯,这个类别几乎完全一致。
18 Britney Spears 🌹🚀
22 Narendra Modi
27 Google
49 Kaka
50 Virat Kohli
54 Neil Patrick Harris
55 Andrés Iniesta
66 Sachin Tendulkar
67 Amitabh Bachchan
68 Cristiano Ronaldo
86 Arvind Kejriwal
88 Mesut Özil
第 5 类:主要是流行歌星—— 模式似乎是流行歌星,但这里也有相当多的名人和演员。明显的异常值有曼联和英超联赛。类似于第 3 类,你可以将其大致归为娱乐类。
14 Lady Gaga
31 Kevin Hart
35 Kim Kardashian
40 P!nk
41 Akshay Kumar
44 Alicia Keys
52 Louis Tomlinson
53 jlo
59 Deepika Padukone
60 Niall Horan
64 Chris Brown
65 Salman Khan
76 Harry Styles.
81 Kylie Jenner
83 방탄소년단
84 Premier League
89 Manchester United
情感与情绪的卡方分析
类别与情感/情绪分布之间是否存在关联?为回答这个问题,我进行了卡方测试,以查看是否存在统计显著的关联。卡方检验比较了情感/情绪的预期分布(如果它们只是随机分配到各个类别)与实际数据中观察到的情况。我计算了每个类别和情感/情绪组合的标准化残差,以测量每个观察值与预期值的偏差(通过热图显示)。你可以将较高的标准化残差粗略解释为该类别对特定情感或情绪的‘更大’倾向。
卡方统计量:2291.23,p 值小于 0.05

作者提供的图像:按簇分类的情绪标准残差
卡方统计量:1535.78,p 值小于 0.05

作者提供的图像:按簇分类的情感标准残差
参与度指标的统计分析
我想了解参与度指标在各个簇中的分布情况。我通过进行一系列统计测试来实现这一点。
克鲁斯卡尔-沃利斯检验:我对每个参与度指标进行了初步的克鲁斯卡尔-沃利斯检验,以了解各簇之间每个参与度指标的中位值是否存在统计显著差异。
注:克鲁斯卡尔-沃利斯检验假设所有比较分布的形状相同,并且样本是随机且独立的。快速检查箱线图显示大多数分布是长尾的。克鲁斯卡尔-沃利斯检验应用于对数变换的调整后参与度指标。




作者提供的图像:参与度指标分布的形状
克鲁斯卡尔-沃利斯检验的结果表明,各簇之间的参与度指标中位值存在统计显著差异。然而,它并未告诉我们哪些簇之间存在差异。我进行了些后续的统计测试,以确定成对的簇差异。
注:p 值小于 0.05 表示统计显著性
favorites
Kruskal-Wallis H-test statistic: 895.7382389032183
P-value: 2.225650711196283e-191
retweets
Kruskal-Wallis H-test statistic: 767.6074631334371
P-value: 1.1759288846534128e-163
quotes
Kruskal-Wallis H-test statistic: 440.94518399410543
P-value: 4.4087653321001564e-93
replies
Kruskal-Wallis H-test statistic: 595.2647170442586
P-value: 2.1329465806092327e-126
邓恩检验:邓恩检验是一种非参数后续检验,用于在克鲁斯卡尔-沃利斯检验后进行成对的多重比较。它在调整多重比较的同时比较两组之间排名数据的差异。邓恩检验的主要假设包括:
-
独立观察:每组中的观察应该彼此独立。
-
序数或连续数据:数据应该是序数或连续的。
-
组间方差(离散度)的同质性:虽然与 ANOVA 等参数检验相比,邓恩检验对方差同质性的假设较不敏感,但它仍然假设数据的离散度在各组之间相似。如果这一假设被违反,测试结果可能不够可靠。
-
随机抽样:数据应通过从感兴趣的总体中随机抽样获得。
邓恩检验告诉我们簇的中位值是否显著不同。对于 p 值为“NaN”的地方,中位数没有显著差异。为了简洁起见,我仅展示了对调整后收藏的邓恩检验结果。
favorite_count_pf
Significant differences in Dunn's test (adjusted p-values):
0 1 2 3 4 \
0 NaN 8.478780e-39 NaN 1.738409e-10 6.224105e-06
1 8.478780e-39 NaN 1.313445e-46 1.228485e-155 1.879287e-87
2 NaN 1.313445e-46 NaN 3.520918e-23 6.119853e-12
3 1.738409e-10 1.228485e-155 3.520918e-23 NaN NaN
4 6.224105e-06 1.879287e-87 6.119853e-12 NaN NaN
5 3.376921e-07 8.379384e-119 9.390205e-16 NaN NaN
5
0 3.376921e-07
1 8.379384e-119
2 9.390205e-16
3 NaN
4 NaN
5 NaN
我生成了所有参与度指标的热图,显示统计上显著的中位数差异方向。为了便于解释,差异以每个粉丝的单位数计。差异表示为行减去列。


作者提供的图像:调整后的点赞和转发的中位数差异(差异表示为行(i)减去列(j))


作者提供的图像:调整后的引用和回复的中位数差异(差异表示为行(i)减去列(j))
4. 观察
簇 0 — 政治簇
情感与情绪:最引人注目的是愤怒的强标准残差(27)。在我看来,由于该簇中政治家的突出地位,这一点并不太令人惊讶——注意到乐观度在这里也很低。这是否会根据更广泛的经济和政治气候而变化?在撰写时,我们已经看到硅谷银行的崩溃、科技行业的大规模裁员、生活成本的增加。愤怒和低乐观度可能是这种情况的反映。
参与度指标:调整后的引用和回复的中位数值(几乎)始终高于其他簇,簇 3 除外。如果我要猜测的话,我会认为这一观察主要由政治辩论驱动。从箱形图中我们可以看到,簇 0 的异常值数量较少,这可能部分是由于该簇相对较小。

作者提供的图像:簇 0 的词云
簇 1 — 新闻媒体:
情感与情绪:负面和正面情绪的标准残差分别为 16 和 -17。这是合理的,因为该簇主要是新闻媒体。奇怪的是,喜悦和悲伤的正残差较高。这可能表明情感模型和情绪模型之间存在一些不一致,尽管仅凭此分析很难确定。中立性也强烈为正,这可能在传播金融新闻时是预期的。争议性公众人物的存在也可能贡献于观察到的残差。
参与度指标:簇 1 的中位参与度相对于其他簇始终较低。这可能与新闻媒体的强中立性有关。

作者提供的图像:簇 1 的词云
簇 2 — 主要是体育媒体
情感与情绪:这个集群有一个强烈的中性标准残差。它也有最低的负面残差和第二低的愤怒残差,但由第二高的乐观残差所平衡。对于体育媒体来说,乐观是有意义的,因为我想象这有很多宣传材料。
互动指标:类似于集群 1,有趣的是,它们都具有强烈的中性残差。这些集群在一定程度上被新闻/媒体主导而非个人,这可能驱动了较低的互动。

作者图片:集群 2 的词云
集群 3 — 流行歌手和脱口秀主持人
情感与情绪:集群 3 在娱乐方面较重。残差更倾向于正面情感和乐观。也许这很有意义,因为娱乐通常应该是轻松和有趣的。
互动指标:集群 3 的互动中位数在各方面都是最高的。通过查看箱型图,我们可以看到离群值的数量和幅度都大于其他集群。考虑到集群内的名字,这似乎并不令人惊讶,它似乎是所有集群中 A-list 名人账户的浓度最高的。离群值给了我们一些迹象,表明这个集群的推文相比之下更有可能“病毒式传播”。我会谨慎地认为这可能只是由于集群 3 相对其他集群的样本效应。然而,较大的中位数互动差异在一定程度上表明了病毒式传播。

作者图片:集群 3 的词云
集群 4 — 运动员
情感与情绪:主要是高度的积极性,一些愤怒,一些乐观,但关键是低悲伤。我认为这很好地捕捉了运动员的高能量特性。
互动指标:中位数关注者调整的转发、收藏和回复都高于除了集群 3 和 0 之外的所有集群。这可能与分享的内容类型有关。例如,这个集群可能有更多的视频内容。中位数引用也低于其他集群,这也许更多地证明了视频和图片内容相对于文字内容的存在。

作者图片:集群 4 的词云
集群 5 — 主要是流行歌手
情感与情绪:该集群具有最低的愤怒和悲伤,以及最高的乐观和正面情感残差。它与集群 3 有相似之处。
互动指标:集群 5 是一个混合体,但与其他娱乐相关集群有类似的模式,只是从互动指标的角度来看效果稍弱。与集群 3 类似,这个集群在互动指标上有一些大的异常值。可以说,集群 5 实际上只是集群 3 的一个子集,从互动和情感的角度来看。

作者提供的图像:集群 5 的词云
5. 局限性与扩展
在结束之前,我应该讨论这种方法的局限性,实际上存在几种。
-
由于我们仅查看每个账户的最后 100 条推文,因此这些集群不一定在时间上稳定。账户的发推方式可能会随着时间的推移而因复杂的外部情况而发生变化。可以将分析扩展以捕捉时间上的关系。
-
该方法依赖于预训练的分类模型。尽管它们在最先进的基准测试中表现良好,但并不完美。尽管如此,将更多的影响指标如讽刺、仇恨言论检测纳入其中将会很有趣。
-
聚类的结果可能会根据分析师选择的超参数而变化。将这种无监督机器学习方法与专业领域知识相结合总是有益的,以确保形成的聚类是合理的。与社交媒体专家合作,听取他们对生成的见解的看法,将会很有趣。
感谢阅读。
[## 通过我的推荐链接加入 Medium - 约翰·阿德约]
我分享数据科学项目、经验和专业知识,以帮助你在你的旅程中。你可以通过...
johnadeojo.medium.com [## 首页 | 约翰·阿德约]
关于我 作为一名经验丰富的数据科学家和机器学习(ML)专家,我热衷于利用...
在 LinkedIn 上关注我
引用
[1] Barbieri, Camacho-Collados, Neves, & Espinosa-Anke (2020 年 10 月 26 日). TWEETEVAL: 统一基准与推文分类的比较评估。Snap Inc., Santa Monica, CA 90405, USA & 计算机科学与信息学学院, 卡迪夫大学, 英国。获取自 arxiv.org/pdf/2010.12421.pdf
[2] Ostertagova, E., Ostertag, O., & Kováč, J. (2014). Kruskal-Wallis 检验的方法论与应用。《应用力学与材料》,611,115–120。
[3] Dinno, A. (2015). 使用 Dunn 检验在独立组中进行非参数配对多重比较。Stata Journal, 15, 292–300. doi.org/10.1177/1536867X1501500117

















浙公网安备 33010602011771号