关于道路网络匹配的更多内容
道路网络匹配的恶作剧
·
关注 发表在 Towards Data Science ·9 分钟阅读·2023 年 2 月 10 日
–
图片由 Denys Nevozhai 提供,来源于 Unsplash
本文的目的是对之前关于同一主题的文章进行补充和修正。在那篇文章中,我介绍了一种从扩展车辆能量数据集¹(EVED)重建丢失地图匹配数据的方法。我的技术探索了三角形的数学特性,以便快速找到道路网络数据库中的丢失数据。不幸的是,代码中的一个细微错误使得在某些情况下失败。
在这里,我展示了对代码的修正,并讨论了应用这些技术重建三万两千多个轨迹的结果。
三角形在地理空间查询中具有强大的属性
网络的细微差别
在上一篇文章中,我展示了两种方法来将地图匹配的位置拟合到道路网络段上,使用了三角形的性质。支持该思想的几何概念是适当的,但代码需要修正。问题出现在节点过滤部分,影响了两种匹配方法,即基于比例和基于距离的方法。让我们再次查看使用基于比例的函数的原始代码:
def get_matching_edge(self, latitude, longitude, bearing=None):
loc = np.array([latitude, longitude])
_, r = self.geo_spoke.query_knn(loc, 1)
radius = self.max_edge_length + r[0]
node_idx, dists = self.geo_spoke.query_radius(loc, radius)
nodes = self.ids[node_idx]
distances = dict(zip(nodes, dists))
adjacent_set = set()
graph = self.graph
best_edge = None
for node in nodes:
if node not in adjacent_set:
adjacent_nodes = np.intersect1d(np.array(graph.adj[node]),
nodes, assume_unique=True)
adjacent_set.update(adjacent_nodes)
for adjacent in adjacent_nodes:
edge_length = graph[node][adjacent][0]['length']
ratio = (distances[node] + distances[adjacent]) / \
edge_length
if best_edge is None or ratio < best_edge[2]:
best_edge = (node, adjacent, ratio)
if bearing is not None:
best_edge = fix_edge_bearing(best_edge, bearing, graph)
return best_edge
该函数使用一个集合来存储所有处理过的相邻节点,以避免重复。遗憾的是,它确实避免了重复。这就是错误所在,因为通过排除一个节点,函数也阻止了所有其他相邻边缘的测试。这种情况在具有多个链接的节点中特别普遍。修正代码需要一个概念上的变化,即移除处理过的道路网络边缘而不是处理过的节点。
但是在使用代码处理几个不同轨迹后出现了另一个问题,即“黑天鹅”事件。当输入的 GPS 位置接近某个节点时会发生什么?在一些罕见情况下,采样的 GPS 位置与某个道路网络节点非常接近,这会引起微妙的浮点不稳定性。结果是选择了一个不合理的边缘,破坏了路径重建。为了解决这个问题,我添加了一个默认参数,设置从输入的 GPS 位置到最近节点的最小距离。当这个距离小于一米(三英尺)时,函数拒绝选择边缘并返回一个空值。这种解决方案比较温和,因为这种情况相对不常发生,而且随后的重建机制可以补偿这一点。
你可以看到下面的修正代码,其中包含了测试过的道路网络边缘集和最小距离检查。
def get_matching_edge(self, latitude, longitude,
bearing=None, min_r=1.0):
best_edge = None
loc = np.array([latitude, longitude])
_, r = self.geo_spoke.query_knn(loc, 1)
if r > min_r:
radius = self.max_edge_length + r[0]
node_idx, dists = self.geo_spoke.query_radius(loc, radius)
nodes = self.ids[node_idx]
distances = dict(zip(nodes, dists))
tested_edges = set()
graph = self.graph
node_set = set(nodes)
for node in nodes:
adjacent_nodes = node_set & set(graph.adj[node])
for adjacent in adjacent_nodes:
if (node, adjacent) not in tested_edges:
edge_length = graph[node][adjacent][0]['length']
ratio = edge_length / (distances[node] + distances[adjacent])
if best_edge is None or ratio > best_edge[2]:
best_edge = (node, adjacent, ratio)
tested_edges.add((node, adjacent))
tested_edges.add((adjacent, node))
if bearing is not None:
best_edge = fix_edge_bearing(best_edge, bearing, graph)
return best_edge
请注意,我还更改了速率公式,使其范围在零到一之间,较大的值对应于更好的拟合。有趣的是,基于速率的函数比基于距离的函数更有效且更快速。
重建旅行
路径重建的第一部分意味着将上述函数应用于输入轨迹的每一点,只保留独特的道路网络边缘。下面的代码展示了一个实现这一点的 Python 函数。
def match_edges(road_network, trajectory):
edges = []
unique_locations = set()
edge_set = set()
for p in trajectory:
if p not in unique_locations:
e = road_network.get_matching_edge(*p, min_r=1.0)
if e is not None:
n0, n1, _ = e
edge = (n0, n1)
if edge not in edge_set:
edge_set.add(edge)
edges.append(edge)
unique_locations.add(p)
return edges
该函数为每个独特的轨迹位置分配一个道路网络边缘。图 1 显示了一个匹配样本。
图 1 — 上面的地图显示了红色的输入轨迹位置和蓝色的匹配道路网络边。(图片来源:作者使用 Folium 和 OpenStreetMap 图像)
如您所见,由于 GPS 采样频率,匹配的链接之间存在一些间隙。重建路径要求我们填补这些间隙,我们通过在道路网络边之间找到最短路径来实现。下面的代码通过保持连接的链接在一起,并使用 OSMnx 的服务填补其他所有间隙来完成这一任务 [2]。
def build_path(rn, edges):
path = []
for e0, e1 in pairwise(edges):
if not len(path):
path.append(e0[0])
if e0[0] != e1[0] and e0[1] != e1[1]:
if e0[1] == e1[0]:
path.extend([e0[1], e1[1]])
else:
n0, n1 = int(e0[1]), int(e1[0])
sp = ox.distance.shortest_path(rn, n0, n1)
if sp is not None:
path.extend(sp[1:])
return path
在对先前的匹配调用此函数后,我们应该将轨迹完全重建为一系列道路网络边。通过将轨迹位置叠加到修补后的道路网络边上,我们可以看到一个连续的结果,如下方的图 2所示。
图 2 — 上面的地图显示了重建的链接序列与原始轨迹位置叠加在一起。(图片来源:作者使用 Folium 和 OpenStreetMap 图像)
最小距离的案例
在审查这个解决方案时,我发现了一个在迭代各个轨迹时的问题。下方的图 3详细描述了这个问题。
图 3 — 上面的图像显示了一个角落案例,在这个道路网络边匹配系统崩溃时发生。轨迹的一个位置正好落在某个节点上,从而欺骗了匹配过程。(图片来源:作者使用 Folium 和 OpenStreetMap 图像)
路由中的虚假循环源于修补过程中的输入数据中的意外值。我们可以通过返回显示算法如何将这些 GPS 位置匹配到道路网络边的视图来深入了解发生了什么。
下方的图 4说明了在叉路口的情况,其中匹配的 GPS 位置与道路网络节点重合(或非常接近)。这可能会导致一些数值不稳定性,产生错误结果。一个稳健的算法应该避免这些情况,但如何做到呢?
图 4 — 一旦看到位置分配,匹配问题变得明显。显然,在上述情况下,GPS 位置非常接近某个道路网络节点,这导致了边选择过程中的不稳定性。(图片来源:作者使用 Folium 和 OpenStreetMap 图像)
考虑后,我决定更改两个匹配算法,并使用与最近的道路网络节点的距离作为包含或排除输入位置的标准。如果这个距离小于给定的阈值(默认为一米或三英尺),匹配函数返回一个空值,通知调用代码无法安全地分配一个链接。这对接下来的步骤是可以的,因为它应该推断缺失的链接,因为该算法仅依赖于道路网络边,而不是输入位置。
现在我们已经对整个算法进行了清理,让我们看看它在整个数据集上的表现。
性能评估
为了评估算法在整个 EVED 上的表现,我创建了一个新的独立脚本,它遍历所有轨迹,匹配边缘,生成路径,并比较原始轨迹长度与重建路径的长度。Python 脚本记录了所有的差异,以便我们可以使用标准统计工具集进行后续分析。
脚本的核心在于其主循环,详见下面的代码。
def process_trajectories():
rn = download_network()
road_network = RoadNetwork(rn)
state = load_state()
if state is None:
state = {
"trajectories": get_trajectories(),
"errors": []
}
save_counter = 0
trajectories = state["trajectories"]
while len(trajectories) > 0:
trajectory_id = trajectories[0]
trajectory = load_trajectory_points(trajectory_id,
unique=True)
if len(trajectory) > 3:
edges = match_edges(road_network, trajectory)
path = build_path(rn, edges)
if len(path) > 0:
diff = calculate_difference(rn, path, trajectory)
print(f"Trajectory: {trajectory_id}, Difference: {diff}")
state["errors"].append((trajectory_id, diff))
trajectories = trajectories[1:]
state["trajectories"] = trajectories
save_counter += 1
if save_counter % 100 == 0:
save_state(state)
save_state(state)
该函数首先从 OpenStreetMap 下载并预处理道路网络数据。由于这是一个长时间运行的脚本,我添加了一个持久化状态,序列化为 JSON 格式的文本文件,并保存了每 100 条轨迹。这一功能允许用户中断 Python 脚本而不会丢失已处理轨迹的所有数据。计算原始输入轨迹与重建路径之间长度差异的函数如下所示。
def calculate_difference(rn, path, trajectory):
p_loc = np.array([(rn.nodes[n]['y'], rn.nodes[n]['x']) for n in path])
t_loc = np.array([(t[0], t[1]) for t in trajectory])
p_length = vec_haversine(p_loc[1:, 0], p_loc[1:, 1],
p_loc[:-1, 0], p_loc[:-1, 1]).sum()
t_length = vec_haversine(t_loc[1:, 0], t_loc[1:, 1],
t_loc[:-1, 0], t_loc[:-1, 1]).sum()
return p_length - t_length
该代码将两个输入转换为地理位置序列,计算它们的累积长度,并最终计算它们的差异。当脚本完成时,我们可以加载状态数据并使用 Pandas 进行分析。
我们可以在下面的图 5中看到两个距离之间的差异分布(标记为"error")。
图 5 — 上面的直方图展示了原始输入轨迹与重建路径之间的距离差异的分布。横轴显示距离(以米为单位)。(图像来源:作者)
为了更详细地查看零附近的情况,我裁剪了直方图,并创建了图 6。
图 6 — 上面的直方图详细说明了差异分布,突出了负差异和大多数正差异。大多数差异很小,暗示匹配算法表现良好。(图像来源:作者)
如您所见,光谱的负部分存在一些差异。不过,它主要是正的,这意味着重建路径的长度通常比轨迹更长,这是可以预期的。请记住,轨迹点很少与道路网络节点匹配,这意味着最常见的情况应该是在一个段的中间匹配。
我们还可以通过绘制箱线图来检查差异的中间五十个百分点,如下图图 7所示。
图 7 — 上面的箱线图展示了分布中间 50%的位置。25%、50%和 75%的百分位数分别为 13.0、50.2 和 156.5 米。我们还可以看到,内点带相对较窄,范围从-202.3 到 371.8 米。(图像来源:作者)
上图包含了有关差异分布和异常值数量的关键信息。使用Tukey’s fences方法,我们可以将 32,528 条轨迹中的 5,332²条标记为异常值。这一比例代表了相对较高的 16.4%值,这意味着可能有更好的重建方法,或者存在大量处理不当的轨迹。我们可以查看一些最剧烈的距离差异异常值,试图理解这些结果。
异常值分析
如前所述,有超过五千条轨迹匹配效果较差。我们可以快速检查其中的一些,来确定采样轨迹与重建之间的差异为何如此显著。我们从一个揭示性的案例开始,即图 8中的轨迹编号 59。
图 8 — 上图所示的轨迹展示了一个地图匹配错误,导致重建过程生成了一条比必要的路径更长的路径。(图片来源:作者使用 Folium 和 OpenStreetMap 图像)
上述案例表明,地图匹配过程错误地将三个 GPS 样本放置在了一条平行的道路上,迫使重建过程找到一个虚假的绕行路径。这种错误可能由于地图匹配过程的参数设置不当造成,需在后续文章中进一步研究。
最后,我们可以看到下方的图 9中的一个非常极端的案例。采样的 GPS 位置距离道路如此之远,以至于地图匹配过程放弃了。正如你所见,重建过程分配了与预期轨迹无关的道路网络链接。
图 9 — 上图中的轨迹包含许多错误,初始的地图匹配过程无法消除这些错误。(图片来源:作者使用 Folium 和 OpenStreetMap 图像)
结论
在本文中,我修正了先前发布的道路网络边缘匹配代码,并使用 EVED 数据集评估了其性能。我使用专门的 Python 脚本收集了性能评估数据,揭示了匹配轨迹中测量距离似乎异常偏差较高的情况。为了了解为何结果可以更好,我将接下来转向原始的地图匹配软件,Valhalla,以尝试修复上述问题。
注释
-
原论文作者将数据集以 Apache 2.0 许可证授权(请参阅VED和EVED GitHub 仓库)。请注意,这也适用于衍生作品。
-
所有图像和计算的来源均在一个 Jupyter notebook 中,来自公共 GitHub 仓库。
参考文献
[1] Zhang, S., Fatih, D., Abdulqadir, F., Schwarz, T., & Ma, X. (2022). 扩展车辆能源数据集(eVED):用于深度学习的车辆旅行能耗的大规模增强数据集。ArXiv. doi.org/10.48550/arXiv.2203.08630
[2] Boeing, G. 2017. OSMnx:获取、构建、分析和可视化复杂街道网络的新方法。Computers, Environment and Urban Systems 65, 126–139. doi:10.1016/j.compenvurbsys.2017.05.004
João Paulo Figueira 在葡萄牙里斯本的 tb.lx by Daimler Trucks and Buses 担任数据科学家。
带有仿真的形态学操作(CV-05)
原文:
towardsdatascience.com/morphological-operations-a-way-to-remove-image-distortion-513d162e7d05
图像处理中的形态学操作最简单解释
·发表于 Towards Data Science ·8 分钟阅读·2023 年 5 月 10 日
–
照片由 Katharina Matt 提供,发布于 Unsplash
动机
我们每天处理大量的图像。这些图像具有不同的强度和分辨率。有时,由于质量原因,我们无法从图像中提取适当的信息。各种图像处理技术在许多方面对我们有帮助。形态学操作是一种重要的技术,通过它我们可以减少图像的失真并对形状进行操作。请看下面的图像。
左侧是应用形态学操作前的图像,右侧是操作后的图像(作者提供的图像)
另一个例子 —
应用形态学操作前后的对比(作者提供的图像)
结果很有趣。但形态学操作的使用案例不仅限于这两种任务。我将在本文中讨论你需要了解的所有形态学操作。
*[注意:主要的形态学分析适用于二值图像。如果你不知道如何创建二值图像,请阅读我之前的文章* [*图像阈值化*](https://medium.com/towards-data-science/thresholding-a-way-to-make-images-more-visible-b3e314b5215c)*。]*
目录
-
**形态学操作详解**
-
**不同类型的形态学操作及其实现**
2.1\. 侵蚀
2.2\. 膨胀
2.3\. 复合操作
3. **结论**
形态学操作详解
形态学操作是一种基于图像形状处理图像的技术。它通过比较邻近像素来构建图像。该过程适用于二值图像({0,1} 或 {0,255})
。
这个过程是如何工作的?
在熟悉形态学操作之前,我们需要了解一些基本术语——结构元素、错过、命中和适合。
图 1:形态学操作的元素(图像由作者提供)
结构元素
它是一个小的形状或模板,分析图像的每个像素与元素下的邻域像素。上图中标记为蓝色的结构元素。
不同的结构元素——
图 2:不同的结构元素(图像由作者提供)
结构元素是根据图像的形状设计的。结构元素的大小可以有不同的尺寸(2x2, 3x3, 5x1, 5x5, 等
)。结构元素包含前景和背景的强度值(即 0 或 1)。它还可以包含无所谓的值。结构元素的一个像素被视为原点。在上面的图像中,我用黑点标记了原点像素。定义原点没有硬性规定,这取决于你。但通常,原点被认为是中心像素。
形态学操作是通过在图像上传播结构元素来完成的。通过比较结构元素下的像素,改变图像中**原点**
位置的像素值。
错过: 如果图像中没有像素与结构元素匹配,则称为错过。见于图 1。
命中: 当结构元素的至少一个像素与图像像素重叠时,称为命中。见于图 1。
适合: 如果结构元素的所有像素与图像匹配,则称为适合。见于图 1。
基本上,有两种类型的形态学操作——
-
*腐蚀*
-
*膨胀*
从这两种操作中可以导出另外两种复合操作——闭合和开操作。
不同类型的形态学操作及其实现
二值图像适合应用形态学操作。
腐蚀
操作很简单。结构元素卷积对象的每个像素。如果结构元素的所有像素都与对象图像像素重叠(满足适合条件),对象图像像素将填充前景像素强度值。否则,它将填充背景像素强度值。
假设背景为 0,前景为 1。
**if Fit -> 1
else -> 0**
我创建了一个模拟以便更好地理解。为了演示目的,我使用了一个强度为 1 的 2x2 结构元素。
图 3:结构元素(作者图像)
我已经拍摄了一张 6x6 像素的图像。白色元素被视为 0 强度值,天蓝色像素被视为 1 的强度值。现在仔细观察下面的模拟。
侵蚀操作(作者 Gif)
结构元素会卷积到给定图像的每个像素上。如果它满足漏检或击中条件,它将把像素更改为原始位置的 0。在模拟中,我用红色显示了像素从 1 变为 0 的地方。最后,我们得到如下结果。
图 4:侵蚀结果(作者图像)
因此,这表明主要图像像素通过侵蚀减少了。
使用 OpenCV 的实际实现
**我们将使用*OpenCV*库来实现形态学操作。在 OpenCV 库中,结构元素被称为*内核*。**
首先,我们导入必要的库。
我将展示图像如何随着不同的结构元素/内核大小而变化。
- 加载图像 —
我们的图像在白色背景上是黑色的。但是 [**OpenCV**](https://docs.opencv.org/4.x/d9/d61/tutorial_py_morphological_ops.html) 期望黑色背景与白色图像对象进行形态学操作。
所以,我们将白色背景转换为黑色,并将黑色对象转换为白色,使用逆二值化
。接下来,我们在逆二值化图像上应用5x5、9x9 和 11x11
内核。由于我们对逆二值化图像应用了形态学操作,*在形态学操作后,我们再次应用逆二值化以保留白色背景和黑色对象。*最后,我们绘制了这些图像。
下面是带有注释的代码。
我们还可以对图像应用多个迭代,使用相同的内核大小。结果显示为 5x5 内核大小下的 1、2 和 3 次迭代。
侵蚀的属性与编码示例
- 这可以用来去除对象的额外噪声。
加载图像 —
去除外部噪声后的结果 —
步骤类似于之前的代码*(逆二值化 → 形态学操作 → 逆阈值化以保留原始图像背景和前景色)*
。
2. 将连接的图像分开。
编码示例 —
加载连接图像 —
它将图像分开。
膨胀
在膨胀操作中,结构元素会卷积到对象图像上。如果结构元素的像素值与对象图像重叠(满足击中条件),则对象图像像素将被填充为前景像素强度值。否则,像素强度值将保持不变。
我们假设背景为 0,前景为 1。
**If Hit -> 1
else -> 0**
为了更好地理解,我创建了膨胀操作的模拟。作为结构元素,我们使用了图-3中的先前结构元素,并保留了与腐蚀操作
中显示的相同图像。
现在,仔细观察下面展示的膨胀操作。
膨胀操作(作者提供的 Gif)
结构元素在对象图像的每个像素上从左到右、从上到下进行卷积。当满足击中或未击中条件时,结构元素原点位置的像素从 0 变为 1。否则,它保持不变。完成操作后,产生如下结果。
图-5: 膨胀结果(作者提供的图片)
所以,膨胀增加了对象图像的像素。
使用 OpenCv 的实际应用
我使用了腐蚀部分中显示的相同图像。代码也与前一部分相同。在这一部分,我们将应用膨胀操作而不是腐蚀操作,并展示不同内核大小和迭代次数的结果。
使用不同的迭代值和相同的 5x5 内核大小,我们可以看到对象图像的形状如何变化。对于 OpenCV 的实现,我们遵循了与腐蚀部分相同的步骤*(反向二值化阈值 → 形态学操作 → 反向阈值以保留原始图像背景和前景颜色)*
。
膨胀操作的属性及示例
- 通过膨胀,我们可以减少/修复图像的断裂。
编码示例 —
我们加载了一个带有断裂的***‘H’***图像。
接下来,我们应用膨胀来修复断裂。
我们已经成功完成了任务。
2. 我们可以通过膨胀去除图像的内部噪声。
编码示例 —
加载带有内部噪声的图像。
在上述图像上应用膨胀后,我们可以轻松得到没有噪声的输出图像。
复合操作
还有一些其他的复合形态学操作。其中,**开操作和闭操作**
是两种广泛使用的操作。下图一览无余地展示了这些操作。
图-6: 开操作和闭操作(作者提供的图片)[1]
开操作通过先进行腐蚀然后进行膨胀来完成。它去除对象上的连接,同时保持形状与主对象相同。见图 6。它有助于去除背景噪声[2]。
我们可以使用 OpenCV 应用开操作,语法如下。
opening = cv.morphologyEx(img, cv.MORPH_OPEN, kernel)
如果我们先对下一步应用膨胀和腐蚀,那么这个过程称为闭操作。这个过程在图-6 中展示。它有助于去除前景图像的噪声[2]。使用 OpenCV 进行闭操作的语法如下。
closing = cv.morphologyEx(img, cv.MORPH_CLOSE, kernel)
还有其他一些复合操作,例如形态学梯度、顶帽和黑帽。
结论
尽管形态学操作的过程很简单,但它可以用于边界提取、填充孔洞、检测食物中的异物、加厚图像对象、提取图像骨架、清除边界等。
如果你认真阅读这篇文章,我相信你将有足够的信心在解决实际问题时应用这些技术。
参考文献
-
Gonzalez, R. C. (2009). 数字图像处理. Pearson education india.
-
docs.opencv.org/4.x/d9/d61/tutorial_py_morphological_ops.html
通过以下链接加入 Medium 支持我的写作。
我计算机视觉系列的其他文章 —
towardsdatascience.com/getting-started-with-numpy-and-opencv-for-computer-vision-555f88536f68?source=post_page-----513d162e7d05--------------------------------
[## 开始使用 NumPy 和 OpenCV 进行计算机视觉 (CV-01)
用 Python 开始你的计算机视觉编程
towardsdatascience.com/getting-started-with-numpy-and-opencv-for-computer-vision-555f88536f68?source=post_page-----513d162e7d05--------------------------------
towardsdatascience.com/how-color-is-represented-and-viewed-in-computer-vision-b1cc97681b68?source=post_page-----513d162e7d05--------------------------------
[## 计算机视觉中颜色表示的全面指南 (CV-02)
颜色空间和颜色模型的详细解释
towardsdatascience.com/how-color-is-represented-and-viewed-in-computer-vision-b1cc97681b68?source=post_page-----513d162e7d05--------------------------------
towardsdatascience.com/blend-images-and-create-watermark-with-opencv-d24381b81bd0?source=post_page-----513d162e7d05--------------------------------
[## 图像混合的最简单指南 (CV-03)
计算机视觉中图像混合和粘贴的最简单指南
towardsdatascience.com/blend-images-and-create-watermark-with-opencv-d24381b81bd0?source=post_page-----513d162e7d05--------------------------------
towardsdatascience.com/thresholding-a-way-to-make-images-more-visible-b3e314b5215c?source=post_page-----513d162e7d05--------------------------------
[## 阈值化 — 使图像更清晰的方式 (CV-04)
通过阈值化从图像中提取更多信息
激励自注意力
为什么我们需要查询、键和值?
·
关注 发表在 Towards Data Science ·9 分钟阅读·2023 年 6 月 10 日
–
…自注意力?
本文的目标是提供一个解释,解释不是自注意力机制如何在变压器中工作,而是为什么它是这样设计的。
我们将从讨论我们希望语言理解模型具备的能力开始,接着进行自注意力机制的互动构建。在这个过程中,我们将发现为什么我们需要查询、键和值来以自然的方式建模单词之间的关系,并且 QKV 注意力是实现这一点的最简单方法之一。
本文对那些曾经接触过变压器和自注意力的读者将最有启发性,但应该对任何熟悉一些基础线性代数的人也能理解。对于那些希望更好地理解变压器的人,我会很高兴地推荐你们阅读这篇博客文章。
所有图片均由作者提供。
变压器通常在序列到序列建模任务中呈现,如语言翻译或更明显的句子完成。然而,我认为从思考序列建模问题和具体的语言理解问题开始更容易。
所以,这里有一个我们想要理解的句子:
让我们稍微思考一下我们如何理解这个句子。
-
Evan 的狗 Riley… 从中我们知道 Riley 是狗的名字,而 Evan 拥有 Riley。
-
…如此超级… 简单来说,“hyper”指的是狗 Riley,影响了我们对 Riley 的印象。
-
…她从不停下 这很有趣。“她”指的是 Riley,因为狗是第一句话的主语。这告诉我们,Evan 的狗 Riley 实际上是母狗,这在之前由于狗名“Riley”常用性别中性而模糊不清。“从不停下”是一组稍复杂的词汇,进一步阐述了“hyper”。
关键是要建立对句子的理解,我们不断考虑词与词之间的关系来增强它们的含义。
在机器学习社区中,通过另一个词 b 的存在来增强词 a 的含义的过程被口语化地称为“a 关注 b”,即词 a 关注词 b。
一个箭头 a => b 表示“a 关注 b”
因此,如果我们希望机器学习模型理解语言,我们可能会合理地希望模型能够让一个词关注另一个词,并以某种方式更新其含义。
这正是我们希望在构建恰如其分地命名的(自)注意力机制的三个部分时模仿的能力。
接下来,我将提出一些斜体的问题。我强烈鼓励读者在继续之前停下来考虑这些问题一分钟。
第一部分
现在,让我们关注词“dog”和“Riley”之间的关系。词“dog”强烈影响词“Riley”的含义,因此我们希望“Riley”能够关注“dog”,因此这里的目标是以某种方式更新词“Riley”的含义。
为了使这个例子更具体,假设我们以每个词的向量表示开始,每个向量的长度为n,基于对词语的无上下文理解。我们将假设这个向量空间相当有序,即在空间中,意义更相似的词与更接近的向量相关联。
所以,我们有两个向量,v_dog 和v_Riley,它们捕捉了这两个词的意义。
我们如何使用 v_dog 来更新 v_Riley 的值,从而获得一个包含“狗”意义的新词“Riley”的值?
我们不想完全用v_dog 替代v_Riley 的值,所以我们可以将v_Riley 和v_dog 的线性组合作为v_Riley 的新值:
v_Riley = get_value('Riley')
v_dog = get_value('dog')
ratio = .75
v_Riley = (ratio * v_Riley) + ((1-ratio) * v_dog)
这似乎还不错,我们已经将“狗”这个词的一部分意义嵌入到了“Riley”这个词中。
现在我们希望尝试将这种形式的注意力应用于整个句子,通过每个词的向量表示更新每个其他词的向量表示。
这里出了什么问题?
核心问题是我们不知道哪些词应该承担其他词的意义。我们还希望有一些衡量标准来确定每个词的值应该对其他词贡献多少。
第二部分
好的。所以我们需要知道两个词之间的相关程度。
进行第二次尝试。
我已经重新设计了我们的向量数据库,使每个词实际上都有两个关联向量。第一个是我们之前拥有的相同值向量,仍然用v 表示。此外,我们现在有单位向量,记作k,它们存储一些关于词语关系的概念。具体来说,如果两个k 向量接近,这意味着这些词的值可能会相互影响。
使用我们新的 k 和 v 向量,我们如何修改之前的方案,以便用 v_dog 更新 v_Riley 的值,并且尊重两个词之间的关系?
我们继续使用之前的线性组合方法,但仅当这两个向量的 k 维接近时。更好的是,我们可以利用这两个 k 向量的点积(它们的范围是 0-1,因为它们是单位向量)来告诉我们应该如何用v_Riley 更新v_dog。
v_Riley, v_dog = get_value('Riley'), get_value('dog')
k_Riley, k_dog = get_key('Riley'), get_key('dog')
relevance = k_Riley · k_dog # dot product
v_Riley = (relevance) * v_Riley + (1 - relevance) * v_dog
这有点奇怪,因为如果相关性为 1,v_Riley 会完全被v_dog 替代,但我们先忽略这一点。
我们接下来考虑将这种想法应用于整个序列。当我们通过k的点积来计算每个词的相关性值时,“Riley”将与其他每个词都有一个相关性值。因此,也许我们可以根据点积的值按比例更新每个词的值。为了简单起见,我们还可以将其与自身的点积包含在内,以保持其自身的值。
sentence = "Evan's dog Riley is so hyper, she never stops moving"
words = sentence.split()
# obtain a list of values
values = get_values(words)
# oh yeah, that's what k stands for by the way
keys = get_keys(words)
# get riley's relevance key
riley_index = words.index('Riley')
riley_key = keys[riley_index]
# generate relevance of "Riley" to each other word
relevances = [riley_key · key for key in keys] #still pretending python has ·
# normalize relevances to sum to 1
relevances /= sum(relevances)
# takes a linear combination of values, weighted by relevances
v_Riley = relevances · values
好了,目前为止这些就足够了。
但我再次声明,这种方法存在问题。并不是说我们的任何想法实现不正确,而是这种方法与我们实际思考单词之间关系的方式存在根本性差异。
如果在本文中有任何一点我 非常非常 认为你应该停下来思考,那就是这里。即使是那些认为自己完全理解注意力机制的人。我们的方法有什么问题?
一个提示
单词之间的关系本质上是非对称的!“Riley”关注“dog”的方式与“dog”关注“Riley”的方式不同。“Riley”指的是一只狗,而不是人类,这比狗的名字更重要。
相比之下,点积是对称操作,这意味着在我们当前的设置中,如果 a 关注 b,那么 b 也会同样强烈地关注 a!实际上,这有些不准确,因为我们在规范化相关性分数,但重点是单词应该有机会以非对称的方式进行关注,即使其他标记保持不变。
第三部分
我们快到了!最后,问题变成了:
我们如何最自然地扩展当前设置以允许非对称关系?
那么,我们可以用另一种向量类型做什么呢?我们仍然有值向量 v 和关系向量 k。现在每个标记还多了一个向量 q。
我们如何修改我们的设置并使用 q 来实现我们想要的非对称关系?
那些熟悉自注意力工作原理的人可能会在这一点上露出会心的微笑。
当“dog”关注“Riley”时,我们计算相关性 k_dog · k_Riley,我们可以改为用 query q_Riley 与 key k_dog 的点积来进行计算。当反向计算时,我们将得到 q_dog · k_Riley —— 非对称相关性!
这就是整体内容,一次性计算每个值的更新!
sentence = "Evan's dog Riley is so hyper, she never stops moving"
words = sentence.split()
seq_len = len(words)
# obtain arrays of queries, keys, and values, each of shape (seq_len, n)
Q = array(get_queries(words))
K = array(get_keys(words))
V = array(get_values(words))
relevances = Q @ K.T
normalized_relevances = relevances / relevances.sum(axis=1)
new_V = normalized_relevances @ V
这基本上就是自注意力机制!
我遗漏了一些细节,但重要的概念都在这里。
总结一下,我们开始使用值向量 (v) 来表示每个单词的意义,但很快发现需要键向量 (k) 来考虑单词之间的关系。最后,为了正确建模单词关系的非对称性质,我们引入了查询向量 (q)。感觉如果我们只能使用点积等操作,3 是每个单词建模关系所需的最小向量数量。
本文的目的是以一种比传统算法优先方法更少让人感到压倒的方式揭示自注意力机制。我希望通过这种更具语言动机的视角,查询-键-值设计的优雅和简洁能够展现出来。
我遗漏的一些细节:
-
我们不再为每个 token 存储 3 个向量,而是存储一个单一的嵌入向量,从中可以提取我们的 q-k-v 向量。提取过程只是一个线性投影。
-
从技术上讲,在这个整体设置中,每个词都不知道其他词在句子中的位置。自注意力实际上是一种集合操作。因此,我们需要嵌入位置信息,通常是通过将位置向量添加到嵌入向量来完成的。这并不是完全简单的,因为 transformers 应该允许任意长度的序列。具体如何在实践中运作超出了本文的范围。
-
单个自注意力层只能表示两个词之间的关系。但是通过组合自注意力层,我们可以建模更高级别的词之间关系。由于自注意力层的输出与原始序列的长度相同,这意味着我们可以对它们进行组合。实际上,transformer 模块就是由自注意力层和逐位置前馈模块组成。堆叠几百个这样的模块,花费几百万美元,你就拥有了一个 LLM!😃
OpenAI 显示出理解第二部分需要 512 个 transformer 模块。
Moto、Pytest 和 AWS 数据库:质量与数据工程的交汇点
Moto 和 Pytest 如何与 AWS 数据库协同工作
·
关注 发布在 Towards Data Science ·9 分钟阅读·2023 年 1 月 3 日
–
图片由Christina @ wocintechchat.com提供,发布在Unsplash
概述
我最近花了很多时间使用 Pytest 来测试 AWS 服务,通过 Boto3。Boto3 是一个非常强大的 AWS SDK,用于处理 AWS 服务。我的研究和实践经验让我发现了一个叫做 Moto 的补充工具!Moto 与 Boto3 配合使用,成为我在最近项目中的测试策略规划以及维护干净生产数据的首选工具。在本文中,我将分享如何以及为何使用 Moto(一个虚假的 Boto3)和 Pytest 来模拟测试 AWS 数据库。我甚至会一步步带你通过使用 Moto 和 Pytest 测试 AWS NoSQL 数据库服务——Amazon DynamoDB 的过程。
请注意:除非另有说明,所有图像均由作者提供。
Moto
Moto是一个能够模拟 AWS 服务编程使用的 Python 库。简单来说 — Boto3是一个面向对象的 API,用于创建、配置和管理真实的 AWS 服务和账户,而Moto 是 Boto3 的模拟版本。虽然 Moto 没有包括 Boto3 提供的每一个方法,Moto 确实提供了相当一部分的 Boto3 方法,以便在本地运行测试。在使用 Moto 时,我强烈建议结合使用 Boto3 文档和 Moto 文档,以获得更详细的方法解释。
Moto 的优点:
-
不需要 AWS 账户
-
避免在 AWS 生产账户中更改实际数据或资源的风险
-
可轻松将 Moto 模拟设置转换为 Boto3 以用于实际应用场景
-
测试运行迅速 — 无延迟问题
-
易于学习和上手
-
免费!— 不会产生 AWS 费用
Moto 的一个缺点:
- 不包括 Boto3 的所有方法 — Boto3 提供了对 AWS 服务的更广泛覆盖
Moto 文档截图:可用于 Amazon DynamoDB 的 Boto3 方法的完整列表,并标记了 Moto “X”可用的方法。欲查看完整列表,请点击这里。
Moto 文档提供了哪些 Boto3 方法可以使用的洞察。例如,Boto3 中略多于一半的DynamoDB 方法可用于 Moto 进行模拟。
Pytest
Pytest是一个用于编写简洁易读测试的 Python 框架。此框架使用详细的断言检查,使得普通的assert
语句非常用户友好。Pytest 具有强大的功能,可以扩展以支持应用程序和库的复杂功能测试。
终端截图 1:执行pytest
命令
测试可以通过一个简单的pytest
命令来执行。pytest
命令会运行所有测试,但可以通过在pytest
后面添加文件路径来运行单个测试文件,如:pytest tests/test_dynamodb.py
。运行pytest
会给出准确的结果,每个测试会显示绿色的“.”表示通过,红色的“F”表示失败,或黄色的“S”表示跳过(如适用)。有两个非常有用的标志我将分享并强烈建议在使用 Pytest 执行测试时使用。请注意:在本节的屏幕截图中,这些不同命令运行之间的代码没有任何变化。唯一的区别是标志。
终端截图 2:使用-s 标志执行pytest
命令
首先是-s
(stdout/stderr 输出)标志。运行pytest -s
会在测试运行时在终端显示所有打印语句。如果没有添加-s
标志,即使代码中有打印语句,终端中也不会出现打印语句。这对于调试非常有用。
终端截图 3:使用-v 标志执行pytest
命令
另一个有用的标志是-v
(详细)标志。使用pytest -v
会在测试运行时在终端提供更多详细信息。这个标志将提供每个测试的类和方法名称,以及所有待运行测试的每个测试完成的累积百分比,以及每个测试的绿色“通过”、红色“失败”或黄色“跳过”指示器。注意,这个标志不会显示打印语句。
终端截图 4:使用组合的-sv 标志执行pytest
命令
专业提示:在运行测试执行命令时,可以组合标志!在使用 Pytest 时,我通常运行pytest -sv
。-s
和-v
标志的组合提供了更具可读性的细节,并且对打印语句有更多的洞察力。
开始使用
Pytest 要求使用 Python3.7 或更高版本。您可以在这里下载最新版本的 Python。然后,需要三个库才能开始。打开您选择的 IDE,在一个全新的目录中,运行以下终端命令以下载所需的依赖项:
pip install boto3 moto pytest
接下来,我们设置“客户端连接”。设置 Moto 连接的过程与 Boto3 非常相似。对于 Boto3,要求提供 AWS 凭证。为了使用 Moto 模拟这个过程,我们将使用虚假的凭证。可以随意设置值,只要类型是字符串即可。
请注意:在实际使用中,保护机密凭证值非常重要,不应将其硬编码到应用程序中。可以通过命令行导出变量或将其保存在环境文件中以限制访问性。 此外,任何区域都可以用于连接,但“us-east-1”通常用于此区域,因为该区域包括所有 AWS 服务和产品。
Pytest 在名为 conftest.py
的文件中利用夹具在模块之间共享。在你的应用程序根目录中,通过运行以下命令创建该文件:
touch conftest.py
并添加以下内容以配置客户端连接:
import boto3
import os
import pytest
from moto import mock_dynamodb
@pytest.fixture
def aws_credentials():
"""Mocked AWS Credentials for moto."""
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
os.environ["AWS_SECURITY_TOKEN"] = "testing"
os.environ["AWS_SESSION_TOKEN"] = "testing"
@pytest.fixture
def dynamodb_client(aws_credentials):
"""DynamoDB mock client."""
with mock_dynamodb():
conn = boto3.client("dynamodb", region_name="us-east-1")
yield conn
AWS 凭证函数和模拟 DynamoDB 客户端函数都实现了 Pytest 夹具装饰器,以便可以在应用程序的其他文件中使用。模拟 DynamoDB 客户端函数使用 Moto 创建与 AWS 的虚假客户端连接,类似于在 Boto3 中的操作。为了本文的目的,我们将以 DynamoDB 为示例 AWS 服务尝试 Moto。如果你有兴趣将 Moto 用于其他 AWS 服务,请查看 Moto 文档。
使用 Moto 测试 DynamoDB
使用以下命令在项目根目录中创建另一个名为 test_dynamodb.py
的文件:
touch test_dynamodb.py
测试文件的第一步是创建一个 DynamoDB 表。由于 Moto 中的许多方法需要已经创建的表,我们将创建一个非常基础的测试表,在使用各种方法时会用到,以避免在每个测试用例中都需要创建一个表。创建此测试表有几种策略,但我将向你展示如何通过实现一个上下文管理器来创建该表。好消息是,上下文管理器来自 Python 的本地库,无需额外下载软件包。测试表可以这样创建:
在单个函数中使用上下文管理器创建测试表后,我们可以继续测试这个测试表是否确实已创建。测试表的表名将是许多 Moto DynamoDB 方法所需的参数,因此我会将其创建为整个测试类的变量,以避免重复并提高可读性。此步骤不是必需的,但有助于节省时间,并减少因在调用不同方法时需要不断重新编写表名值而产生的语法错误的机会。
在相同的 test_dynamodb.py
文件中,在上下文管理器下方创建一个测试类,以便将所有相关测试(*“my-test-table”*的测试)分组在一起。
如上所示,dynamodb_client
参数作为参数传递给测试方法。这来自于 conftest.py
文件,在该文件中我们已经建立了与 Amazon DynamoDB 的模拟连接。第 4 行显示了表名变量的可选声明。然后在第 9 行,我们调用上下文管理器进入 test_create_table
方法,以便访问“my-test-table”。
从那里,我们可以编写一个测试来验证表是否成功创建,通过调用 Moto 的 .describe_table() 和 .list_tables() 方法,并断言表名是否存在于响应输出中。要运行测试,只需执行以下命令(如果需要,您可以添加额外的标志):
pytest
这个命令应该会返回测试通过的结果:
终端截图 5:执行 pytest
命令以测试表的创建
使用 Moto 进一步测试 DynamoDB
现在,您的测试文件已准备好进行任何额外的 DynamoDB 测试方法,您可以使用 Moto 探索,例如向“my-test-table”添加项:
你看到的测试可以直接放在 TestDynamoDB 类中 test_create_table
方法下。test_put_item
方法也使用了上下文管理器,以使用最初在文件顶部创建的相同测试表。在每个方法开始时,以及在每个测试套件中调用上下文管理器时,“my-test-table” 都处于初次创建时的原始状态。请注意:状态在测试方法之间不会持久化。
我创建了一个 GitHub 存储库,其中包含了本文中的示例测试,并且还增加了更多内容!我的示例代码扩展了文章中的测试,包括以下 DynamoDB 测试:
-
创建表
-
将项添加到表中
-
从表中删除项
-
删除表
-
添加表标签
-
移除表标签
[## GitHub - taylorwagner/moto-aws-data
通过在 GitHub 上创建帐户来贡献 taylorwagner/moto-aws-data 的开发。
github.com](https://github.com/taylorwagner/moto-aws-data?source=post_page-----ae58f9e7b265--------------------------------)
这个存储库还包括一些 AWS RDS 的测试。在我的示例代码中,我将测试文件组织到自己的目录中,并对测试进行了分组以确保测试的一致性。我挑选了每个服务的不同方法来展示 Moto 提供的范围。Moto 还有许多其他方法未包含在我的示例代码中。请务必查看 Moto 的文档!
最终想法
将 Pytest 固件和 Python 上下文管理器结合使用,Pytest 的易用性和可扩展性框架与 Moto 的结合是验证 AWS 数据库服务使用情况的绝佳组合。尽管 Moto/Pytest 的组合可能不适合测试真实的 AWS 服务和账户,但它是练习和更好地理解如何测试真实 AWS 账户的足够选项,且不会带来安全漏洞、篡改重要数据或产生昂贵的、不必要的账单。只需记住,与 Boto3 相比,Moto 的方法范围有限。我发现,与 Moto 的模拟响应一起工作会为你提供利用 Boto3 处理真实 AWS 用例所需的理解。我希望我的文章和示例代码能帮助你满足 AWS 数据库的需求。
资源
- Moto 的 GitHub 仓库:
[## GitHub - spulec/moto: 一个允许你轻松模拟基于 AWS 的测试的库…]
Moto 是一个库,使你的测试可以轻松模拟 AWS 服务。假设你有以下的 Python 代码…
- 我的示例代码托管在 GitHub 上。你可以查看测试并在你的机器上运行。我还包括了 AWS RDS 测试:
[## GitHub - taylorwagner/moto-aws-data]
通过在 GitHub 上创建账户来为 taylorwagner/moto-aws-data 的开发做出贡献。
使用 Neo4j 的电影推荐
原文:
towardsdatascience.com/movie-recommendations-with-neo4j-adaad7c9bf2b
使用 Python 和 Neo4j 构建一个简单的电影推荐系统
·发布在 Towards Data Science ·阅读时间 7 分钟·2023 年 2 月 19 日
–
图片由作者使用稳定扩散和bytexd.com/get-started-with-stable-diffusion-google-colab-for-ai-generated-art/
中的代码创建
介绍
创建推荐是机器学习的一个常见用例。在这篇文章中,我们将演示如何使用图数据库创建一个简单的电影推荐系统。所提出的方法不是最先进的。但使用图数据库的实现简单且易于解释。它们可以成为一个简单推荐系统的起点,该系统可以快速提供结果和/或作为评估更复杂系统的基准。
如果读者希望进行实验,他/她可以使用 Neo4j 的沙盒 和 Google 的 Colab 在一两分钟内准备好系统。对于本文,我们将使用 GroupLens.org(即 “1M 数据集”)中的数据。我们还将使用一个小数据集来创建一个包含少量节点的最小图,以便可以轻松检查计算。所有用于最小图的代码和数据可以在作者的 GitHub 中找到。
请注意:
-
Neo4j Graph Data Science 插件应安装在 Neo4j 中(它已在 Neo4j 的沙盒中安装)
-
在 Python 中,应安装“neo4j-driver”和“graphdatascience”库。
要在(2)中安装 Python 库,可以使用 pip
!pip install neo4j-driver
!pip install graphdatascience
连接到 Neo4j
在加载必要的库后,第一步是连接到 Neo4j。可以使用以下代码片段完成此操作
DB_ULR = 'bolt://xxxxx:xxxx'
DB_USER = 'neo4j'
DB_PASS = 'xxxxx'
gds = GraphDataScience(DB_ULR, auth=(DB_USER, DB_PASS))
如果我们使用 Neo4j 的沙盒,我们可以在“通过驱动程序连接”选项卡中找到 URL 和密码。
Neo4j 沙盒的连接详细信息(作者提供的 Neo4j 沙盒屏幕截图)
将数据加载到 Neo4j
如介绍中所提到的,我们将使用电影评分数据。特别是,我们将使用 MovieLens 1M 数据集。该数据集包含来自 6000 部电影的 1 百万条评分。它由三个独立的文本文件组成:
-
movies.dat:电影数据,格式为 MovieID::Title::Genres
-
users.dat:用户数据,格式为 UserID::Gender::Age::Occupation::Zip-code
-
ratings.dat:评分数据,格式为 UserID::MovieID::Rating::Timestamp
电影、用户和评分文本文件的前五行(图像由作者提供)
我们将创建两种类型的节点。一种代表用户,另一种代表电影。我们还将创建用户节点和电影节点之间的关系,以表示用户对电影的评分。作为该关系的属性,我们将使用评分分数。图数据库的模式如下所示。
图数据库的模式(作者提供的屏幕截图)
使用图数据科学库将 pandas 数据框加载到 Neo4j 是相当简单的。例如,下面的代码加载 users.dat
gds.run_cypher('create constraint if not exists for (n:User) require (n.id) is node key')
create_customer_res = gds.run_cypher('''
unwind $data as row
merge (n:User{id: row.UserID})
set n.Gender = row.Gender
set n.Age = row.Age
return count(*) as custmers_created
''', params = {'data': users.to_dict('records')})
Ratings.dat 文件相当大,不能一次性加载。因此,我们需要将数据框分割并分块加载。
for chunk in np.array_split(ratings,200):
if i%10 == 0:
print(i)
create_rated = gds.run_cypher('''
unwind $data as row
match (u:User{id: row.UserID}), (m:Movie{id: row.MovieID})
merge (u)-[r:RATED]->(m)
set r.Rating = row.Rating
return count(*) as create_rated
''', params = {'data': chunk.to_dict('records')})
i = i+1
最小示例图
为了帮助读者理解我们将要使用的方法,我们将使用以下最小图作为示例。它包含:
-
三个用户节点,编号为 1、2 和 3
-
四个电影节点
-
七条评分关系,括号中可以看到实际评分
最小示例图(图像由作者提供)
使用 cypher 查找相似电影
使用 cypher,找到与给定电影相似的电影非常简单。给定一部电影 m1,可以找到所有以最高评分(5)对其进行评分的用户,然后返回这些用户也评分为优秀的其他所有电影。通过计算连接 m1 和每部其他电影的路径数量,我们可以计算相似度得分。
例如,查找与“玩具总动员(1995)”相似电影的 cypher 查询如下。
# Check similar movies
similar_movies = gds.run_cypher('''
MATCH(m1:Movie)-[r1]-(u:User)-[r2]-(m2:Movie)
WHERE m1.Title CONTAINS 'Toy Story (1995)'
AND m2.Title<>'Toy Story (1995)'
AND r1.Rating=5 AND r2.Rating=5
RETURN m2.Title,m2.Genres,count(DISTINCT(u)) as number_of_paths
ORDER BY common_users DESC
''')
similar_movies.head()
在我们的最小图示例中,这将返回“勇敢者游戏(1995)”,它与“玩具总动员(1995)”通过两条路径连接。一条路径经过用户 1,另一条路径经过用户 2。
当我们使用我们常规的包含一百万条评分的图时,与“玩具总动员(1995)”最相似的五部电影是
-
《星球大战:第四集 — 新希望(1977)》
-
《玩具总动员 2(1999)》
-
《夺宝奇兵(1981)》
-
《星球大战:第五集 — 帝国反击战……》
-
《肖申克的救赎(1994)》
而对于“黑客帝国(1999)”,前五名是
-
《星球大战:第四集 — 新希望(1977)》
-
星球大战:帝国反击战……
-
夺宝奇兵(1981)
-
美国美人(1999)
-
第六感(1999)
一些读者可能对这些结果感到复杂,这是可以理解的。使用这种方法时,受欢迎的电影和高评分的电影往往占据主导地位。事实是,一百万条评分还不足以建立一个推荐系统。使用 MovieLens 和各种推荐方法的经验表明,增加评分数量可以改善推荐。然而,应该注意的是,我们仅通过简单查询就能利用图找到相似的电影。更复杂的方法是使用类似于下一节描述的查找相似用户的方法。
向用户推荐电影
使用 Neo4j,我们可以应用协同过滤来向用户推荐电影。协同过滤方法的高层次描述是,向用户推荐新电影的过程分为两个步骤:
-
我们找到与我们的用户相似的用户,
-
我们使用步骤(1)中找到的用户评分来建议新电影。
计算用户相似度
我们将使用 Jaccard 相似度来检测相似用户。在我们的图论设置中,两个节点之间的 Jaccard 相似度是它们都连接的节点数量与连接到至少一个节点的节点数量的比率(不包括我们计算相似度的两个节点)。
在我们的最简图示例中,用户节点 1 和 2:
-
都连接到“玩具总动员(1995)”和“勇敢的心(1995)”
-
连接到“玩具总动员(1995)”、“勇敢的心(1995)”和“呼吸等待(1995)”
因此,用户 1 和用户 2 的 Jaccard 相似度为 2/3。
同样,用户 1 和用户 3:
-
都连接到“玩具总动员(1995)”
-
连接到“玩具总动员(1995)”、“勇敢的心(1995)”、“呼吸等待(1995)”和“黄金眼(1995)”
因此用户 1 和用户 3 的 Jaccard 相似度为 1/4。
Neo4j 的图数据科学库可以计算 Jaccard 相似度。首先,我们需要创建一个子图(或者 Neo4j 称之为投影),包含我们在计算 Jaccard 相似度时需要考虑的节点和关系。
# Create projection
create_projection = gds.run_cypher('''
CALL gds.graph.project(
'myGraph',
['User', 'Movie'],
{
RATED: {properties: 'Rating'}
}
);
''')
然后,我们计算 Jaccard 相似度并将结果存储在 pandas 数据框中。
# Get user similarity
users_similarity = gds.run_cypher('''
CALL gds.nodeSimilarity.stream('myGraph')
YIELD node1, node2, similarity
RETURN gds.util.asNode(node1).id AS UserID1, gds.util.asNode(node2).id AS UserID2, similarity
ORDER BY similarity DESCENDING, UserID1, UserID2
''')
pandas 的前五行包含我们最简图示例图的用户相似度(图像由作者提供)
最后,我们在用户节点之间创建一个新的关系,作为它们之间计算出的相似度的属性。
# Create Similar relationship
i=1
for chunk in np.array_split(users_similarity.query('UserID1>UserID2'),10):
print(i)
create_similar = gds.run_cypher('''
unwind $data as row
match (u1:User{id: row.UserID1}), (u2:User{id: row.UserID2})
merge (u1)-[r:SIMILAR]->(u2)
set r.Similarity=row.similarity
return count(*) as create_rated
''', params = {'data': chunk.to_dict('records')})
i = i+1
为了向用户(user1)推荐电影,我们计算用户尚未评分的电影的排名,使用其他用户观看过的电影的加权平均评分,其中权重为 Jaccard 相似度。
计算加权平均评分的公式(图像由作者提供)
我们还添加了连接用户(user1)到电影的路径数量的对数。这是因为我们想要提升与(user1)连接的有多个用户的电影。对应的 cypher 查询是
# Check similar movies
similar_movies_for_user = gds.run_cypher('''
MATCH (u1:User)-[r1:SIMILAR]-(u2)-[r2:RATED]-(m:Movie)
WHERE id(u1)=$id
AND NOT ( (u1)-[]-(m))
RETURN m.Title,m.Genres,Sum(r1.Similarity*r2.Rating)/sum(r1.Similarity)+log(count(r2)) as score
ORDER BY score DESC
''',params = {'id':2})
对于我们的最小图示例,用户 3 的结果是:
-
《勇敢者游戏》(1995 年),评分为 5.69
-
《呼吸等待》(1995 年),评分为 3.00
用户的前 10 部电影推荐(左侧)和用户的前 10 部评分最高的电影(右侧)(图片由作者提供)
结论
希望这篇文章已经展示了使用图数据库来快速创建推荐引擎的好处。虽然这不是最先进的技术,但它易于实现和维护。作为附加 bonus,我希望这篇文章也提供了一些将 Python 与 Neo4j 结合的有用技巧。
使用的数据集引用:
F. Maxwell Harper 和 Joseph A. Konstan. 2015. 《MovieLens 数据集:历史与背景》。ACM 互动智能系统交易(TiiS)5, 4, 文章 19(2015 年 12 月),19 页。DOI=dx.doi.org/10.1145/2827872
多臂老虎机应用于执行算法中的订单分配
找到利用与探索之间的正确平衡
·
关注 发表在 Towards Data Science · 6 min 阅读 · 2023 年 3 月 2 日
–
困惑的机器人以毕加索风格观察三台单臂老虎机。来源:DALL-E 2。
在不确定性下做出决策是各个领域的专业人士面临的常见挑战,包括数据科学和资产管理。资产经理在选择多个执行算法来完成交易时会遇到这个问题。算法之间的订单分配类似于赌徒面临的多臂老虎机问题,他们必须决定玩每台老虎机的次数、顺序以及是否继续当前机器或切换到另一台。在这篇文章中,我们描述了资产经理如何根据实际执行成本最佳地分配订单到可用算法中。
示例
对于每个订单,我们采取行动a,将其分配给K个算法中的一个
Eq. 1: 分配订单到 K 个算法中的一个的可能行动集合。
行动a的价值是该算法的预期执行成本
Eq. 2: (未观察的) 行动 a 的预期执行成本,即选择某个算法。
假设K = 3,并且算法的预期执行成本为
Eq. 3: (未观察的) 三个算法的预期执行成本。
如果你事先知道行动值,解决问题会非常简单。你将始终选择预期执行成本最低的算法。现在假设我们开始按照图 1 中所示在三个算法之间分配订单。
图 1: 在三个算法之间分配订单及其相关执行成本的示例。来源:作者。
我们仍然不确定行动值,但在一段时间t后,我们有了估计:
Eq. 4: (观察的) 基于截至时间 t 的信息,行动 a 的预期执行成本。
例如,我们可以构建每个算法的执行成本¹的经验分布,如图 2 所示。
图 2: 一段时间 t 后每个算法的执行成本的经验分布。来源:作者。
将所有订单分配给预期执行成本最低的算法可能看起来是最佳方法。然而,这样做会阻止我们收集其他算法性能的信息。这体现了经典的多臂老虎机困境:
-
利用已经学到的信息
-
探索以了解哪些行动能获得最佳结果
目标是最小化平均执行成本,在分配P个订单之后:
Eq. 5: 订单分配问题的目标函数。
使用策略解决问题
为了解决这个问题,我们需要一个动作选择策略,它告诉我们如何基于当前信息S分配每个订单。我们可以将策略定义为从S到a的映射:
Eq. 6: 动作选择策略的定义。
我们讨论了最著名的多臂赌博机问题策略²,这些策略可以分为以下几类:
-
半均匀策略: 贪婪 & ε-贪婪
-
概率匹配策略: 上置信界限 & 汤普森采样
贪婪
贪婪方法将所有订单分配给估计值最低的动作。该策略总是利用当前知识以最大化即时奖励:
Eq. 7: 贪婪方法的动作选择策略。
ϵ-贪婪
ε-贪婪方法大多数时候表现得很贪婪,但以概率ε随机选择次优动作:
Eq. 8: ϵ-贪婪方法的动作选择策略。
该策略的一个优点是,它在极限情况下会收敛到最优动作。
上置信界限
上置信界限(UCB)方法选择具有最低动作值减去一个与交易算法使用次数成反比的项,即Nt(a)。该方法在非贪婪动作中选择,依据其实际最优潜力及这些估计的相关不确定性:
Eq. 9: 上置信界限(UCB)方法的动作选择策略。
汤普森采样
汤普森采样方法,如汤普森(1933)所提,假设对动作值有一个已知的初始分布,并在每次订单分配后更新分布。该方法根据动作的后验概率选择最佳动作:
Eq. 10: 汤普森采样方法的动作选择策略。
策略评估
在实际应用中,策略通常通过遗憾来评估,遗憾是与最优解的偏差:
Eq. 11: 遗憾作为一系列动作的函数的定义。
其中μ是最小执行成本均值:
Eq. 12: 选择最优动作的期望执行成本。
动作是策略的直接结果,因此我们也可以将遗憾定义为所选策略的函数:
Eq. 13: 遗憾作为动作选择策略π的函数的定义。
在图 3 中,我们在示例中模拟了上述策略的遗憾。我们观察到上置信界限方法和汤普森采样方法表现最佳。
图 3:针对虚拟订单分配问题的不同动作选择策略的模拟遗憾。来源:作者。
订单分配?拥抱不确定性!
虚拟示例模拟结果强烈表明,单纯依赖贪婪方法可能无法获得最佳结果。因此,在制定订单分配策略时,融入并衡量执行成本估算的不确定性至关重要。
脚注
¹ 为确保执行成本的经验分布的可比性,我们需要分配类似的订单或使用无关订单的成本指标进行评估。
² 在算法的执行成本依赖于顺序特征的情况下,上下文强盗算法是更合适的选择。要了解更多关于这种方法的信息,我们推荐 Barto & Sutton (2018) 的第 2.9 章作为入门。
³ 我们强烈建议阅读 Russo 等 (2018) 作为学习 Thompson 采样的杰出资源。
额外资源
以下教程 / 讲座对我理解多臂强盗问题非常有帮助。
行业
学术界
参考文献
[1] Sutton, R. S., & Barto, A. G. (2018). 强化学习:导论。麻省理工学院出版社。
[2] Russo, D. J., Van Roy, B., Kazerouni, A., Osband, I., & Wen, Z. (2018). 关于 Thompson 采样的教程。机器学习基础与趋势®,11(1),1–96。
[3] Thompson, W. 1933. 在两个样本的证据下,一个未知概率超过另一个的可能性。生物统计学。25(3/4): 285–294。
[4] Thompson, W. R. 1935. 关于配额理论。美国数学杂志。57(2): 450–456。
[5] Eckles, D. 和 M. Kaptein. 2014. 在线自助法的 Thompson 采样。arXiv 预印本 arXiv:1410.4009。
如果你对更多内容感兴趣,可以查看我下面的一些文章:
用于 VWAP 执行算法的线性成本分解,允许更快且更细致的算法交易……
medium.com ## 引言:概率分类的机器学习视角
从预测标签到预测概率的指南
towardsdatascience.com [## 超越传统资产回报建模:拥抱厚尾。
针对预渐近性的统计推断指南。
多维探索是可能的!
原文:
towardsdatascience.com/multi-dimensional-exploration-is-possible-212b99171706
(至少在数学上)
·发表于 Towards Data Science ·13 分钟阅读·2023 年 10 月 5 日
–
图片由作者使用Midjourney制作
探索多维世界是科幻故事和电影中的一个常见主题。虽然穿越这些世界仍然是一种幻想,但数学可以帮助我们接近如此荒诞的想法。如果你曾经想过,“又一天没有计算矩阵的行列式”,那就请稍等。线性代数可能比你想象的更接近现实!在你的手机、电脑和流媒体应用中,复杂到简单的数据结构的转换每天都在发生。这些操作是真实的、数学上的多维门户。本文解释了主成分分析—它是什么,为什么重要,以及它是如何工作的。
Carl Sagan 解释了……
有一个视频,由Carl Sagan在 YouTube 上讲解,他解释了来自高维世界的访客会如何看待他们的低维对等物。这个美丽的课程源自 Sagan 著名的系列节目宇宙。正如他节目中的许多摘录一样,Sagan 巧妙地解释了在“平面世界”中的二维个体如何体验来自三维苹果的访问。实际上,从高维到低维空间的数学并不超出线性代数课程的内容。正如 Sagan 在视频结束时所说的,“虽然我们无法想象四维世界,但我们完全可以思考它。”
一个类似于卡尔·萨根在视频中展示的苹果投影。图片由作者使用MidJourney制作。
投影到 2D 空间中的苹果可能看起来不像真正的苹果,但它可以给我们苹果的形状和大小的概念。下面的图片展示了一个苹果和一个胡萝卜投影到 2 维表面上的情况。我们可能仅通过任何一个投影就能识别出苹果。另一方面,可能很难意识到下方的投影对应的是一个胡萝卜。这意味着每次我们将高维对象投影到低维空间时,我们都会丢失一些信息。然而,这些投影帮助我们思考一个高维对象在我们世界中的样子。
图 2. 胡萝卜和苹果的 2D 投影。图片由作者制作。
根据我们用来投影对象的平面不同,我们会对它们的形状和特征有不同的理解。因此,虽然投影可以帮助我们思考更高的维度,但投影的方式对我们的理解至关重要。再看看胡萝卜的下方投影。仅通过这个投影,有办法知道这就是胡萝卜吗?可能没有。选择其他两个投影中的任何一个可能更好。但是,我们如何选择它们呢?如果我们试图思考一个 10 维的对象,我们如何选择出我们的大脑能够处理的最佳三个维度?
超越苹果和胡萝卜
要理解这一点,我们首先需要意识到,多维空间不仅与胡萝卜和苹果的世界相关。每个维度可以表示数据点集合中的某个特定参数或特征。如果我们取一组人并测量他们的五个特征,我们可以将这些个体绘制在一个 5 维空间中。再次强调,我们无法想象这个空间,但我们可以通过将这些 5 维点投影到一个我们可以可视化的 3 维或 2 维空间来思考它。在下面的示例中,生活在 2 维世界中的人只能看到投影到下方(xy)平面的三个球体。否则,他们只能看到两个,这并不是很好地思考这组 3 维球体的方式。
图 3. 空间中点的 2D 投影。图片由作者制作。
投影数据到最佳维度的问题可以通过主成分分析 (PCA)来解决。这个名字可能看起来与我们目前讨论的内容无关,但它源于我们在此分析中寻找数据集的主要成分。在这种情况下,成分意味着可以投影原始数据的维度或平面。PCA 如何找到最佳成分或维度?它查看每对参数的变异性。差异越大,对其真实行为的良好投影就越容易。我们无法识别胡萝卜的原因是,因为我们无法看到胡萝卜的主要特征。通过查看这种投影,我们无法知道它是长的、底部较细以及顶部有一些绿色叶子。我们只看到一个圆形的模糊物体,可能代表其他任何东西。PCA 帮助我们以一种可以看到主要特征的方式观察一个物体或数据点组。
降维投影
矩阵乘法是一种将点投影到低维空间的方法。这意味着,从数学上讲,从 3 维降到 2 维,或者从 10 维降到 2 维。下图展示了一组二维中的点如何被投影到一维线上。此图取自一个包含有关 PCA 的额外细节的Python 笔记本。图中的线可以在任何地方定义。将原始点与包含定义每条线的单位向量的矩阵相乘,可以得到原始点在这些线上的投影。因此,红色和绿色的交叉点表示来自二维世界的蓝色点在一维中会是什么样子。注意红色交叉点的变化比绿色交叉点的变化更大。这意味着在绿色交叉点的世界中,原始点看起来不会像在红色交叉点的世界中那样有所不同。这里要注意的主要思想是,即使我们有方法在不同的维度之间移动(至少在数学上是这样!),我们如何投影原始数据会影响我们观察和思考高维对象的方式。
图 4. 将二维中的点投影到一维线上的示意图。图像由作者制作。
在一个类似于我们在图片中看到的胡萝卜和苹果的例子中,我们可以想象一组 3 维的点及其在不同平面上的投影。再次根据我们选择的投影平面,我们将得到不同的结果。这时 PCA 就派上用场了。通过 PCA,我们可以定义最佳的投影组合,从而观察到最大的变异。因此,PCA 帮助我们获得一个与红色交叉点更相似的投影,而不是绿色交叉点,这反过来会更好地代表在更高维空间中的点集。
PCA 依赖于许多你可能在线性代数课程中学习过的内容。应用 PCA 的步骤可以总结如下:
协方差矩阵
生成一个协方差矩阵:协方差测量两个变量之间关系的强度。如果我们有一组属于 10 维空间的点,那么我们将会有 45 种不同的维度对组合。如果我们计算这些组合的方差,就可以了解两个变量之间关系的方向和强度。这个计算结果写在协方差矩阵中,如图片所示。注意每个值出现了两次,因为每对变量在矩阵中会出现两次。请注意,在计算协方差之前,数据通常会被标准化,因为每个维度可能包含不同量纲的值。具有较大量纲的变量会主导主成分。标准化数据可以确保所有变量具有相同的量纲,从而使 PCA 对每个变量给予相等的权重。
图 5. 协方差矩阵。图像由作者制作。
特征向量和特征值
计算协方差矩阵的特征向量和特征值:是的!你可能在某次线性代数讲座中听到过这个名字!而且,是的!这可能听起来非常陌生!然而,理解的关键在于特征向量和特征值告诉我们关于特定变换的一些特殊信息。记住矩阵乘法本质上是一个变换,我们通过求和和乘法来改变一些点。特征向量代表一个特定的方向,在执行变换后方向保持不变。另一方面,特征值代表特征向量在变换过程中被拉伸或压缩的程度,而方向不变。因此,特征向量和特征值非常重要,因为它们告诉我们在变换后哪些方向保持不变。
下图显示了对同一组数据应用的三种不同线性变换。这种线性变换是水平剪切。在图中显示的所有情况下,特征向量始终是相同的:(1, 0)。这意味着任何位于单位向量(1,0)上的点在变换后将保持不变。注意点(1,0)和(-1,0)没有被变换改变,任何位于特征向量(1,0)上的点,如(0.25, 0),(-0.75, 0)等,也没有被改变。
图 6. 特征向量的图示解释。图片由作者制作。
转换
使用特征向量将原始数据转换到较低维空间。特征值会告诉你哪些特征向量最重要,应该投影到这些向量上。因此,在像图 4 所示的图中,红色交叉点对应的特征向量的特征值高于绿色交叉点的特征向量。这是因为红色交叉点的变异性大于绿色交叉点。如果我们想从一维视角对二维点有一个良好的了解,使用第一个向量来转换点比使用第二个向量更好。你可以看到,一个对应于两个维度的(nx2)数组如果乘以形状为(2x1)的特征向量,就会被转换为一维。如果我们想将一个 10 维的原始数据集转换为 3 维或 2 维,我们可以遵循相同的过程。
投影
通常,将转换后的点投影到原始维度上是有用的。对于这个例子,这意味着将转换后的 1D 点重新绘制到 2D 空间中,这就是图 4 所示的内容。要将点投影到原始维度上,你可以将转换后的数组乘以特征向量数组的转置。在这种情况下,这意味着将(nx1)乘以(1x2)。这将得到图 7 所示的(nx2)数组。在这个图中,特征向量 1 和特征向量 2 的特征值分别为 39.2 和 5.3。这意味着特征向量 1 比特征向量 2 代表了更好的投影,可以从图中看到,因为红色交叉点的分布比绿色交叉点的分布更大。
图 7. 二维点投影到主成分上。图片由作者制作。
数值胡萝卜
让我们将之前的步骤应用到一个类似于本文开头展示的问题中。下图展示了一个 3D 点分布,看起来像胡萝卜(有关如何构建这个胡萝卜的更多信息,请参见这个Python 笔记本)。它还展示了每个平面的投影。我们可以看到,观察 XY 平面上的投影的人会发现很难意识到这些点实际上代表了胡萝卜,而 ZX 和平面 Z 的投影有更好的表示效果。因此,在这种情况下,如果我们想在 2D 中可视化这个 3D 对象,最好将其投影到 ZX 或 YZ 平面上。
图 8. 旋转胡萝卜形状的 3D 点投影到不同平面上。图像由作者制作。
如果最佳投影平面不是那么明显呢?假设现在我们要投影的点群如下图所示。注意这与之前的胡萝卜相同,但已旋转。如果我们将这些点投影到之前看到的三个平面上,我们将得到胡萝卜的旋转版本。最佳投影是通过胡萝卜中间的平面,而这正是我们可以通过对原始点应用 PCA 来获得的投影。通过找到点之间变化最大的方向,PCA 可以帮助我们在高维空间中可视化数据点。尽管 2D 胡萝卜图像与 3D 胡萝卜不完全相同,但它仍然是一个很好的表示,胜过其他任何平面。有关如何使用 scikit-learn 在 Python 中应用 PCA 的更多信息,请查看这个 Python 笔记本。
图 9. 旋转胡萝卜形状的 3D 点投影到不同平面上。图像由作者制作。
图 10. 左侧:旋转胡萝卜形状的 3D 点投影到由 2 个主成分定义的平面上。右侧:根据不同平面对 3D 胡萝卜进行的 2D 转换。彩色图像表示根据主成分的转换。图像由作者制作。
PCA 的应用
PCA 最重要的应用之一与图像处理相关。这意味着对图像进行分析以分类或识别其所属类别。图像识别算法如今在我们的手机和相机中广泛使用。要了解 PCA 如何应用于图像处理,我们应首先了解图像如何存储和处理。以下是一个非常简单的示例,帮助我们理解 PCA 在图像处理中的应用。
假设我们有一张灰度图像需要处理。处理这种图像的多种方法之一是将这张图像转换为一个数组,该数组有多行多列,分别对应图像中的像素数量(请参阅这个Python 笔记本以了解如何加载图像并对其应用 PCA)。因此,在每个像素上,我们将有一个通常范围从 0 到 255 的数字,代表强度值。零表示黑色,255 表示白色,任何中间值是不同的灰度。这意味着以下 50x50 像素的灰度图像可以表示为一个 50x50 的数组。
图 11. 分辨率为 50x50 像素的章鱼灰度图(此图像来自Pixilart)
50x50 的数组也可以理解为在 50 维空间中绘制的 50 个点。虽然我们无法想象这样的空间,但我们可以思考一下!这 50 个点每个都有 50 个不同的特征,这些特征最终形成了我们在图 11 中看到的图片。与之前类似,我们可以尝试找到这些数据的主成分。这可以帮助我们将点绘制在较低维空间中。下图展示了如果我们使用不同数量的主成分,投影会是什么样子。注意主成分越少,图像越模糊。此外,注意我们可以使用 25 个主成分得到非常相似的图像。
图 12. 根据用于转换原始图片的主成分数量得到的章鱼灰度图。
我们对这张图片所做的与之前在空间和平面上的点所做的没有不同。在将图片转换为 25 个主成分的情况下,我们所做的事情是:
-
计算原始数据集的主成分。这意味着我们构建了一个 50x50 的协方差矩阵,并从该矩阵中计算了特征向量和特征值。
-
我们提取了具有最大特征值的 25 个特征向量。然后我们将原始数据集(nx50)与特征向量数组(50x25)相乘。这将原始数据转换为 25 维空间。在我们之前的例子中,这就像是从 3D 胡萝卜变为 2D 胡萝卜。
-
由于我们对通过这种降维方式丢失了多少信息感兴趣,我们可以将新的数据集投影到 50 维空间中。这就像是将 2D 中的点投影到原始 3D 空间中。
-
图 13 展示了使用 25 个主成分的原始图像与投影图像之间的比较。它还展示了如果我们使用 50x25 而不是 50x50 的分辨率所得到的转换图像。
图 13. 左:章鱼的原始图片。中:使用 25 个主成分转换后的图像。右:投影到 50 维空间的转换图像。
现在我们有了一个降维后的图像,可以考虑这有多么有用。我们不再需要存储和分析 50x50=2500 个数字的数组,而是使用了 50x25=1250 个数字的数组。这可以帮助我们加快多个过程的速度,并更好地理解数据。
以下图片展示了对高细节度肖像进行的降维处理。较少的主成分使图像变得模糊,更难以辨认。然而,我们可以看到,即使使用原始尺寸的一半,我们仍然可以得到适用于许多应用的图像。
图 14. 使用不同主成分的灰度肖像(原始图像是使用Midjourney制作的)
结论
尽管我们的日常体验根植于三维世界,但我们已经看到,可以在更高维度中思考数据结构。尽管听起来很离奇,但数学是连接我们与不同维度世界的门户。然而,更重要的是,从低维到高维的转换不仅仅与科幻和大片有关。数据在多个维度中的转换和投影在我们与手机、应用程序和计算机的日常互动中无缝发生。在高维空间中处理和分析数据使我们能够理解模式并得出属于我们日常生活的结论。尽管本文所解释的过程无疑是对更复杂过程的简化,但主要思想依然有效。因此,下次你看到主角通过魔法门户进入不同维度的电影时,请记住,至少我们有了数值基础来思考这样的旅程。正如卡尔·萨根所说:“虽然我们无法想象四维世界,但我们可以很好地思考它。”
用于神经退行性疾病分类的多层神经网络
关于开发多层神经网络以分类阿尔茨海默病磁共振图像的逐步指南
·发表于 Towards Data Science ·阅读时间 23 分钟·2023 年 3 月 3 日
–
在 2019 年 12 月,在Medium和Toward Data Science的支持下,我开始撰写一系列文章和 Python 教程,解释机器学习(ML)是如何工作的,以及它应如何应用于生物医学数据。从零开始构建机器学习算法的想法,没有像TensorFlow或Scikit-learn这样的框架的支持,这是有意为之的,目的是解释机器学习的基础,特别是对那些对所有概念有深入理解的读者有益。此外,过度引用的框架是强大的工具,需要特别关注参数设置。因此,深入研究它们的基础可以向读者解释如何充分利用它们的潜力。
随着时间的推移,我意识到诊断成像可能代表了生物医学领域主要受益于人工智能帮助的部分。如果没有其他因素,这个领域为优化机器学习算法提供了许多线索。确实,诊断成像是一个医学领域,其中变异性和定义难度与需要抽象化病理过程的本质相碰撞。
今天,更高效的人工智能系统辅助医生和科学家完成挑战性任务,例如基于影像的早期疾病诊断。人工智能的贡献不仅限于帮助快速诊断疾病,还可以熟练揭示病理过程的根本原因以及组织或器官何时开始退化。以阿尔茨海默病(AD)为例,这是一种导致神经退化和脑组织丧失的慢性神经疾病(McKhann 等人,1984 年):照顾 AD 患者的成本趋于上升,因此及时诊断变得越来越必要。
在这篇文章中,我希望引起您的注意,我们将构建一个多层感知器(MLP 神经网络),作为阿尔茨海默病磁共振成像数据(ADMRI)图像分类系统,数据来自 Kaggle。在我之前的文章中,我描述了基于逻辑回归(LR)的图像检测和结果预测的其他直接方法。在那些文章中,我们看到如何成功地对 MNIST 手写数字或 Zalando 数据进行分类,准确率达到 97%。然而,当训练集的结构达到一定复杂性时,例如 ADMRI,这些方法可能不会那么高效。
对于本文中实现的代码,我们将使用 Python。我建议使用Jupyter notebook和 Python 版本≥3.8。代码需要最低限度的优化计算,特别是涉及线性代数的部分。我们将使用如Pandas、NumPy、matplotlib和SciPy等包,这些包是 SciPy.org的一部分,是一个基于 Python 的数学、科学和工程的开源软件生态系统。此外,我们还将导入matplotlib,一个 Python 数据可视化库。此外,还将创建一个opt对象,来自scipy.optimize,用于对梯度进行优化。最后一个库是“pickle”,它对于打开 pickle 格式的文件至关重要。
建议:
为了更好地理解概念,例如 sigmoidal 函数、成本函数和线性与逻辑回归中的梯度下降,我建议读者首先阅读我之前关于这些主题的文章:
1. 一元或多元线性回归;
3. 使用 One-vs-All 检测黑色素瘤;
4. Python 中用于图像识别的 One-vs-All 逻辑回归。
这些是这里将理所当然地使用的基本准备概念。此外,许多概念将在这里简要提及,因此在阅读这些建议的数字顺序的其他文章之前,请等待开始阅读本文。
数据集。
阿尔茨海默病磁共振成像数据(ADMRI)可以从Kaggle下载。数据包含两个文件夹:第一个包含 33984 张增强的 MRI 图像,第二个包含原始图像。数据有四类图片在训练和测试集中:1) 非痴呆,2) 中度痴呆,3) 轻度痴呆,4) 极轻度痴呆。每张图片包含约 200 x 200 像素,对应许多特征(40000)。
对于这个练习,我提供了相同数据集的转换版本,你可以从以下链接下载。这“轻量”版本包含了缩减到 50 x 50 分辨率的整个增强数据集。解压后得到的 pickle 文件是一个pandas dataframe,由两列组成,一列包含每张图片的 2500 个特征,另一列包含相应的标签,定义如下:
Label 1: Non-Demented
Label 2: Moderate Demented
Label 3: Mild Demented
Label 4: Very Mild Demented
首先,我们需要上传所有必要的库来打开数据集和显示图像:
import pandas as pd
import numpy as np
from matplotlib.pyplot import imshow
import pickle
然后,打开 pickle 文件,发现包含的数据框,输入以下代码到 Jupyter notebook 单元格中:
with open('AugmentedAlzheimer.50X50.training.dataset.pickle', 'rb') as handle:
ALZ = pickle.load(handle)
我们在这里称之为“ALZ”的数据框包含了所有图片及其相应标签。在新单元格中输入“ALZ”将显示数据结构:
图 1. 训练数据集结构(图片由作者提供)
现在,将每个数据框的列转换为两个不同的numpy对象,X为训练数据集,y为标签向量*。(将数据转换为 numpy 对象是线性代数计算所必需的):
X = np.stack(ALZ['X'])
y = np.int64(ALZ['y'])
X向量包含 33,984 个项目,每个项目包含一个 2500 灰度值的向量。numpy 的shape方法显示 X 的结构:
操作X时,我们可以通过索引访问单个项目;例如,要访问第一张图片(索引=0)的前 250 个值,输入:
图 2. 显示 X[0]内容(前 250 个值)(图片由作者提供)
每个像素对应 0–255 范围内的特定灰度值,其中 0 是黑色,255 是白色。现在我们可以访问 X 向量,matplot函数*imshow()*将显示一个 2500 像素的灰度图像,框架为 50 x 50 像素的 2D 表示:
'''
Reshape a 2500-values vector extracted from one of the images
stored in the vector X (i.e.: #12848).
Use the NumPy method .reshape, specifiying the double argument '50'
then show the image with the function imshow, specifying the argument
cmap='gray'
'''
image = X[12848].reshape(50, 50)
print('image:', 12848)
print('label:', y[12848])
imshow(image, cmap='gray')
运行代码时,图像编号#12848 显示如下:
图 3. 来自增强阿尔茨海默 MRI 数据集的灰度图像,表示图像#12848 对应于标签 2:中度痴呆。(图片来源于www.kaggle.com/datasets/uraninjo/augmented-alzheimer-mri-dataset
)
我们更愿意使用一个简单的函数随机显示从数据集中挑选的五十张图像,而不是逐一操作。以下是代码:
'''
plotSamplesRandomly
Function for visualizing fifty randomly picked images, from the dataset
'''
def plotSamplesRandomly(X, y):
from random import randint
import matplotlib.pyplot as plt
%matplotlib inline
# create a list of randomly picked indexes.
# the function randint creates the list, picking numbers in a
# range 0-33983, which is the length of X
randomSelect = [randint(0, len(X)) for i in range(0, 51)]
# reshape all the pictures on the n X n pixels,
# where n = sqrt(size of X), in this case 50 = sqrt(2500)
w, h =int(np.sqrt(X.shape[1])), int(np.sqrt(X.shape[1]))
fig=plt.figure(figsize=(int(np.sqrt(X.shape[1])), int(np.sqrt(X.shape[1]))))
# Define a grid of 10 X 10 for the big plot.
columns = 10
rows = 10
# The for loop
for i in range(1, 51):
# create the 2-dimensional picture
image = X[randomSelect[i]].reshape(w,h)
ax = fig.add_subplot(rows, columns, i)
# create a title for each pictures, containing #index and label
title = "#"+str(randomSelect[i])+"; "+"y:"+str(y[randomSelect[i]])
# set the title font size
ax.set_title(title, fontsize=np.int(np.sqrt(X.shape[1])/2))
# don't display the axis
ax.set_axis_off()
# plot the image in grayscale
plt.imshow(image, cmap='gray')
# Show some sample randomly
print('\nShow samples randomly:')
plt.show()
运行plotSampleRandomly()
函数,传入参数 X 和 y:
plotSamplesRandomly(X, y)
输出见图 4:
图 4. 可视化从阿尔茨海默病磁共振成像数据集中随机挑选的图像。每个瓦片代表一幅磁共振图像,附有其识别编号和标签(例如,#10902; y:2,表示图像编号 10902 对应于“中度痴呆”诊断),这些图像来自www.kaggle.com/datasets/uraninjo/augmented-alzheimer-mri-dataset
此外,我还制作了原始阿尔茨海默病磁共振成像数据集的简化版本,可以从这个 Kaggle link下载。可以使用此数据集进行测试。
重要的是:本练习的唯一目的是展示将 MLP 神经网络应用于阿尔茨海默病磁共振图像的基本概念,并且仅用于实验目的,不用于临床使用。
Logistic Unit 模型
让我们从一些历史定义开始:在神经网络(NN)中,我们定义了一类从生物神经细胞网络中汲取灵感的 AI 算法。它们诞生的具体目的是试图模拟人脑功能。其运作假设的基础,正是在八十年前由著名神经科学家进行的研究中建立的。1943 年,沃伦·麦卡洛克和沃尔特·皮茨发表了一项具有重大意义的工作,题为*“神经活动中固有思想的逻辑演算”*,首次描述了生物神经网络如何协同工作以完成复杂任务的计算模型。
从那一年开始,许多 NN 模型接踵而至。例如,1957 年,弗兰克·罗森布拉特定义了感知器的概念,这是一种能够对输入和输出之间的线性关系进行分类的人工神经元。我们可以毫无疑问地说,人工智能在那一刻诞生了。然而,尽管对这些模型的兴趣大幅增长,但当时的巨大硬件限制使得人工智能仍停留在希望和梦想的世界中,而非现实世界。我们不得不等到 1986 年,D. E. Rumelhart 发表了一篇论文,引入了反向传播训练的概念,从而提出了一种能够找到成本函数最小值的新算法,自动化计算成本函数的导数,简而言之,Rumelhart 发明了梯度下降。
当我们实现神经网络时,以现代意义上的神经网络,我们指的是对一组生物神经元所做的简化模型。在这种简化中,神经元只是一个逻辑单元,通过各种树突连接到其他类似的输入单元,并通过其轴突产生输出值。
图 5. 逻辑单元模型(作者提供的图片)
逻辑单元模型简化了生物神经元。体部(图 5 中的蓝色圆圈)整合来自输入神经元(X 向量)的值,包括基础神经元(红色),通过电缆连接到逻辑单元,并通过其轴突产生输出值。该输出对应于假设模型hθ。表示hθ的一种方式是*g(z)*函数,它是一个 sigmoid 函数(或逻辑函数),因此是非线性的,其公式可以写成这样:
g(z)函数使用翻译后的θ 向量与X 向量的乘积(我们称这个乘积为z)作为参数,可以定义为:
假设 X 向量取值为 0 或 1,g(z) 当 z = [θ₀X₀ + θ₁X₁ + θ₂X₂] 时,计算输出可能为 0 或 1 的概率。Sigmoid 逻辑函数也称为激活函数,这个定义源于每个生物神经元的生理功能,它根据接收到的输入类型被激活。然后,这个输入与θ 向量(逻辑单元中的权重向量)代数地相乘。对于所有回归模型(线性或逻辑),逻辑单元都有一个成本函数,它可以记录我们距离hθ的最小值有多远,并帮助我们找到最佳的 θ。
但在理解神经网络的成本函数之前,让我们通过一些直觉和两个实际示例来了解神经网络是如何工作的。我们将描述的这个神经网络是故意简单的;它没有隐藏层,仅由一个逻辑单元和几个输入神经元组成。
简单神经网络直觉 I:AND 门
例如,假设我们希望我们的神经网络学习逻辑门 AND:
图 6a. 将 AND 门应用于简单神经网络(作者提供的图片)
图 6a 展示了将 AND 门实现到逻辑单元中的示例,它利用了我们故意指定的三个θ值:θ向量确实等于三元组θ₀*=-3,θ₁=+2,θ₂=+2(绿色值)。红色的表格部分表示 AND 真值表。两个 AND 操作数是 X1 和 X2,它们可以取 0 或 1 的值,而 X0 是表示偏置的输入神经元,默认值为 1。输出函数 Y 对应于假设模型 hθ,由函数 g(z)实现。根据z的不同,函数g(z)返回接近 0 或 1 的值,这取决于z*位置映射到的 sigmoid。
图 6b:AND 门逻辑函数的放大图(作者提供的图像)
例如,对于 X1 = 0 和 X2 = 0,g(z)将等于 0.05,因为 X 向量中唯一不等于 0 的值是 X0,即存储在偏置神经元中的值(-3)。实际上,g(-3)=0.05 映射到 sigmoid 时接近于零。相反,当 X1 = 1 和 X2 = 1(唯一使 X1 AND X2 为真的条件)时,g(z)等于 g(-3+2+2) = g(1),映射到 sigmoid 时返回 0.73,这个值远远超过 0.5 的阈值,接近 1。在其他两个剩余情况下,即 X1=0,X2=1 和 X1=1,X2=0,g(z)等于 g(-1)=0.27,这个值再次接近 0(图 6b)。所有四种情况下的 g(z)输出(图 6a 中的黄色)返回了 AND 门的预期结果。sigmoid 逻辑函数的优点是它可以平滑地计算输入神经元信号组合的 0/1 概率,像生物神经元一样生理地激活。此外,sigmoid 函数将有助于保证梯度下降算法寻求θ值时收敛到全局最小值,避免处理具有多个局部最优的非凸成本函数的问题(详见癌症恶性预测的逻辑回归以获取有关非凸成本函数问题的详细讨论)。sigmoid 逻辑函数的 Python 实现如下:
# Sigomid Logistic (Activation) Function
def sigmoid(z):
g = 1.0 / (1.0 + np.exp(-z))
return g
请复制并粘贴到新的笔记本单元中。你可以运行该函数,将不同的z值作为参数传递给它,并查看它产生的输出,例如:
或者是否可以实现一个出色的函数来可视化 g(z)及其输出,如下所示:
# Plot the Sigmoid function, and its output:
def plotSigmoid(z):
import numpy as np
from matplotlib import pyplot as plt
plt.rcParams["figure.figsize"] = [7.50, 3.50]
plt.rcParams["figure.autolayout"] = True
x = np.linspace(-10, 10, 100)
# setting the axes at the centre
fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
ax.spines['left'].set_position('center')
ax.spines['bottom'].set_position('zero')
ax.spines['right'].set_color('none')
ax.spines['top'].set_color('none')
ax.xaxis.set_ticks_position('bottom')
ax.yaxis.set_ticks_position('left')
plt.plot(x, sigmoid(x), color='blue')
plt.plot(0, sigmoid(0), marker="o", markersize=5,
markeredgecolor="black", markerfacecolor="black")
if(z!=0):
plt.text(0, sigmoid(0), " 0.5")
plt.plot(z, sigmoid(z), marker="o", markersize=5,
markeredgecolor="red", markerfacecolor="red")
value = "{:5.3f}".format(sigmoid(z))
plt.text(z, sigmoid(z), str(" z="+str(z)+"; value="+str(value)))
plt.show()
例如,运行 plotSigmoid,对于 z = 1,即 0.73,将显示如下:
(作者提供的图像)
简单神经网络直观 II:OR 门
关于 AND 门,我们可以用一个涉及 OR 门的其他示例来进行实验。
图 7a:OR 门在逻辑单元中的应用(图片由作者提供)
通过将偏置神经元 X0 的θ值从 -3 更改为 -1 并重新进行所有计算,输出现在反映了 OR 门:我们有三个 X1 和 X2 输入对,它们被映射到 g(z) sigmoid 函数中,产生接近 > 0.5、接近 1 的值。
图 7b:OR 门的逻辑函数放大图(图片由作者提供)
多层感知机(MLP)模型
多层神经网络由一个输入层、一个或多个隐藏层以及一个输出层组成。所有层(但输出层除外)都包含一个偏置神经元,所有单元完全连接。隐藏层的数量区别于经典的 MLP 和深度学习神经网络。由于其架构的特性,神经网络可以帮助我们提出复杂的非线性假设。
图 8:多层神经网络(图片由作者提供)
观察 MLP 的模型表示,我们发现 MLP 运作的基础计算与逻辑回归中的计算类似。这一观察确认了 MLP 实质上是一个“超级”逻辑回归器。结构(图 8)显示了三个神经层。第一层是输入层,由X 向量组成。第二层由基于X 乘以 θ 的结果激活的隐藏节点组成。这些激活的结果成为第三层,即输出层的输入。第三层也包括激活单元。唯一的区别是,它的结果应对应于假设模型 hθ。在这里,我们必须使用条件动词,因为神经网络的核心问题正是尝试最小化假设与来自 X 乘以 θ 产品的预测结果之间的误差。
图 9:多层神经网络的模型表示。每个激活单元的计算根据图 8 中表示隐藏层单元的彩色圆盘进行颜色编码。(图片由作者提供)
最小化过程体现在所谓的训练阶段。它包括找到那些θ值(权重),使得Xθ能够通过梯度下降符合hθ的要求。我们必须将一个维度为Sj乘以*(n + 1)的θ矩阵相乘,其中Sj是J*层的激活节点数量:
(图片由作者提供)
和,
(图片由作者提供)
实现 MLP 神经网络以增强阿尔茨海默病 MRI 分类
我们将创建一个单层 MLP。隐藏层的单元数计算为输入单元的 2/3 加上输出单元的数量。这个设置保证了训练成功,尽管其他架构在隐藏层使用了更多或更少的激活单元。输入单元的数量取决于数据集中单个图像的像素数量,而输出单元的数量对应于类别标签。整个算法分为三个主要函数:
1\. init
2\. training
3\. testing
这三个主要函数包含其他函数,我们将在适当的时候进行描述。
这里介绍的 NN 是一个 前向传播 MLP;因此,输入数据通过网络向前传播以生成输出。隐藏层处理数据并移动到下一个层。在前向传播过程中,激活发生在每个隐藏层和输出层节点上。激活函数是加权和的计算。基于加权和,激活函数被应用于使神经网络通过使用偏置非线性流动。
初始化
上传数据集后,定义 X 和 y 向量:
with open('AugmentedAlzheimer.50X50.training.dataset.pickle', 'rb') as handle:
ALZ = pickle.load(handle)
X = np.stack(ALZ['X'])
y = np.int64(ALZ['y'])
X 向量将包含 33,984 个项目,每个项目包含一个 2500 灰度值的向量。init 函数使用方法 X.shape 根据 X 的大小初始化 NN 架构:
'''
function "init"
Neural Network initialization and parameter setup
'''
def init(X, y):
I = X.shape[1] # n x n MRI input size (2500)
H1 = int(X.shape[1]*2/3) + max(set(y)) # hidden units size:
# 2/3 of the input units + the number of output units (1670)
O = max(set(y)) # output units size or labels (4)
m = X.shape[0]
ini_Th1 = thetasRandomInit(I, H1)
ini_Th2 = thetasRandomInit(H1, O)
# Unroll parameters
ini_nn_params = np.concatenate((ini_Th1.T.flatten(), ini_Th2.T.flatten()))
ini_nn_params = ini_nn_params.reshape(len(ini_nn_params), 1)
print('\nNeural Network Parameters initialized!\n')
print('\nNeural Network structure:\n')
print('Input layer neurons:', I)
print('1st hidden layer neurons:', H1)
print('Output layer neurons:', O)
return ini_nn_params, I, H1, O
参数 I, H1, 和 O 对应于输入层、隐藏层和输出层的大小;ini_nn_params 包含 θ 向量的“展开”版本(Th1 对于输入层,Th2 对于隐藏层)。使用展开的扁平化向量将减少处理过多向量的复杂性,使代码更易读。init 函数接受向量 X 和 y 作为参数,但需要子函数 thetasRandomInit 嵌套在内部,用于随机化 Th1 和 Th2。(这些必须在训练阶段之前随机生成)。
thetasRandomInit 函数可以如下实现:
'''
function "thetasRandomInit"
Random initialization of thetas
'''
def thetasRandomInit(L_in, L_out):
e_init = 0.12
W = np.random.rand(L_out, 1 + L_in) * 2 * e_init - e_init
return W
W 矩阵包含一个随机值向量,它是基于一个 e_init 值实现的,默认设置为 0.12。请将之前的代码复制粘贴到一个新单元格中并运行。调用 init 函数并定义 NN 参数 ini_nn_params, I, H1, O 类型如下:
ini_nn_params, I, H1, O = init(X, y)
init 函数将初始化这四个变量,并生成输出:
Neural Network Parameters initialized!
Neural Network structure:
Input layer neurons: 2500
1st hidden layer neurons: 1670
Output layer neurons: 4
该函数描述了 NN 的结构,包括 2500 个输入单元,1670 个隐藏单元,以及四个输出单元。
训练
对于使用我们上传的 Alzheimer’s MRIs 数据集(X 和 y 向量)训练 NN,我们需要创建一个 training 函数,这只是一个将 nnCostFunction 传递给 opt 对象的“包装”代码:
'''
function "training"
Neural Network training
'''
def training(ini_nn_params, I, H1, O, X, y):
import scipy.optimize as opt
lambd = 1
print('\nTraining Neural Network... \n')
result = opt.minimize(fun=nnCostFunction,
x0=ini_nn_params,
args=(I, H1, O, X, y, lambd),
method='TNC',
jac=True,
options={'maxiter': 1000, "disp": True})
params_trained = result.x
Th1_trained = np.reshape(params_trained[0:(H1 * (I + 1)), ],
(H1, I + 1))
Th2_trained = np.reshape(params_trained[(H1 * (I + 1)):, ],
(O, H1 + 1))
print('\nTrained.\n')
return Th1_trained, Th2_trained
函数 training 使用 scipy 对象 opt 来最小化神经网络成本函数 (nnCostFunction) 的 梯度下降。该函数通过 “maxiter” 参数确定实现最小化目标所需的迭代次数。正如 opt.minimize 的参数列表中所指定的,这里使用的方法是 TNC(截断牛顿法),它可以最小化标量函数,如 nnCostFunction,无论是单变量还是多变量。最后,training 将返回两个经过训练并准备好用于测试阶段的 θ 向量,Th1 和 Th2。
nnCostFunction 代表了这里展示的最复杂的例程,是整个算法的核心。现在我们将更详细地解释它的作用。我建议在学习 nnCostFunction 代码时考虑图 8 中的模式和图 9 中的计算。
'''
nnCostFunction
Implements the Neural Network Cost Function
'''
def nnCostFunction(nn_params, I, H1, O, X, y, lambd):
# 1\. RESHAPING
# Reshape nn_params back into the parameters Th1 and Th2,
# the weight matrices
Th1 = np.reshape(nn_params[0:(H1 * (I + 1)), ],
(H1, I + 1))
Th2 = np.reshape(nn_params[(H1 * (I + 1)):, ],
(O, H1 + 1))
# 2\. SETUP OF Y
# Setup the output (y) layout
m = X.shape[0]
Y = np.zeros((m, O))
for i in range(m):
Y[i, y[i] - 1] = 1
# 3\. INITIALIZE J, and THETAS
J = 0
Th1_grad = np.zeros(Th1.shape)
Th2_grad = np.zeros(Th2.shape)
# 4\. PREPARE ALL THE VECTORS FOR THE FORWARD PROPAGATION
# Six new vectors are generated here: a1, z2, a2, z3, a3, and h.
# The vector a1 equals X (the input matrix),
# with a column of 1's added (bias units) as the first column.
a1 = np.hstack((np.ones((m, 1)), X))
# z2 equals the product of a1 and Th1
z2 = np.dot(a1, Th1.T)
# The vector a2 is created by adding a column of bias units
# after applying the sigmoid function to z2.
a2 = np.hstack((np.ones((m, 1)), sigmoid(z2)))
# z3 equals the product of a2 and Th2
z3 = np.dot(a2, Th2.T)
a3 = sigmoid(z3)
# The Hypotheses h is = a3
h = a3
# 5\. MAKE REGUKARIZED COST FUNCTION.
# calculate P
p = np.sum([
np.sum(np.sum([np.power(Th1[:, 1:], 2)], 2)),
np.sum(np.sum([np.power(Th2[:, 1:], 2)], 2))
])
# Calculate Cost Function
J = np.sum([
np.divide(np.sum(np.sum([np.subtract(np.multiply((-Y), np.log(h)),
np.multiply((1-Y), np.log(1-h)))], 2)), m),
np.divide(np.dot(lambd, p), np.dot(2, m))
])
# 6\. FORWARD PROPAGATION
# d3 is the difference between a3 and y.
d3 = np.subtract(a3, Y)
z2 = np.hstack((np.ones((m, 1)), z2))
d2 = d3.dot(Th2) * gradientDescent(z2)
d2 = d2[:, 1:]
# GRADIENTS
# Delta1 is the product of d2 and a1\.
delta_1 = np.dot(d2.T, a1)
# Delta2 is the product of d3 and a2\.
delta_2 = np.dot(d3.T, a2)
# Regularized Gradients.
P1 = (lambd/m) * np.hstack([np.zeros((Th1.shape[0], 1)), Th1[:, 1:]])
P2 = (lambd/m) * np.hstack([np.zeros((Th2.shape[0], 1)), Th2[:, 1:]])
Theta_1_grad = (delta_1/m) + P1
Theta_2_grad = (delta_2/m) + P2
grad = np.hstack((Theta_1_grad.ravel(), Theta_2_grad.ravel()))
return J, grad
函数 nnCostFunction() 接受参数 nn_params,即我们之前创建的 ini_nn_params,它包含了 θ 向量的展开版本;I, H1, O(分别是输入层、隐藏层和输出层的大小);两个向量 X, y;以及对应于正则化参数 lambda 的 lambd。该函数返回 J 和 θ 梯度向量。
-
首先,nnCostFunction 将 ini_nn_params 重新调整为参数 Th1 和 Th2,考虑到这两个向量的原始大小。
-
代码需要创建输出层的设置,首先生成一个由 n-label-columns * n-X_samples 组成的向量 Y,初始值为零,然后用 1 填充,以这种方式对输出进行编码:
Structure of Y (the output layer):
1,0,0,0 for label 1
0,1,0,0 for label 2
0,0,1,0 for label 3
0,0,0,1 for lable 4
新的编码随后被赋值给 y 向量。
3. 用零初始化 成本 J 和 θ 向量,用于 梯度下降。
4. 为 “前向传播” 准备所有向量。这里生成了六个新向量:a1, z2, a2, z3, a3 和 h。向量 a1 等于 X(输入矩阵),在第一列添加了一列 1(偏置单元)。向量 z2 等于 a1 和 θ1 (Th1) 的乘积。向量 a2 是在对 z2 应用 sigmoid 函数后添加了一列偏置单元创建的。向量 z3 等于 a2 和 θ1 (Th2) 的乘积。最后,向量 z3 被复制到向量 h 中,表示假设 hθ。这个步骤表面上看似不重要,但为了更好的可读性,复制 z3 到 h 是必要的。
5. 该部分函数实现了我们希望被正则化的 成本函数。请回顾逻辑回归成本函数的公式:
图 10:逻辑回归成本函数。(作者提供的图片)
正则化版本是:
图 11:正则化逻辑回归成本函数。(作者提供的图片)
该实现通过将 lambda 乘以 probability p(在公式中未出现)来优化正则化;p 计算为 θ1 和 θ2 的幂的总和。
6. 这部分代码将执行前向传播。在执行此任务时,代码必须创建一个新的向量 d3,用于收集 a3 和 y 之间的差异;在这里,z2 被处理,添加了一列 1,而新的向量 d2 将包含 d3 和 Th2 的乘积,以及 Gradient Descent 的输出,应用于 z2。
Gradient Descent 函数的实现如下:
'''
gradientDescent
returns the gradient of the sigmoid function
'''
def gradientDescent(z):
g = np.multiply(sigmoid(z), (1-sigmoid(z)))
return g
要运行 training 函数,请调用它,并指定 ini_nn_params、I、H1、O、X 和 y 作为参数:
Th1_trained, Th2_trained = training(ini_nn_params, I, H1, O, X, y)
注意:
下载后,你可以通过输入以下命令上传这两个文件:
# Upload thetas from the two files
with open('ALZ.50.df_theta_1.pickle', 'rb') as handle:
Th1_trained = pickle.load(handle).values
with open('ALZ.50.df_theta_2.pickle', 'rb') as handle:
Th2_trained = pickle.load(handle).values
测试
测试函数用于测试神经网络训练:
'''
testing()
'''
def testing(Th1, Th2, X, y):
m, n = X.shape
X = np.hstack((np.ones((m, 1)), X))
a2 = sigmoid(X.dot(Th1.T))
a2 = np.hstack((np.ones((m, 1)), a2))
a3 = sigmoid(a2.dot(Th2.T))
pred = np.argmax(a3, axis=1)
pred += 1
print('Training Set Accuracy:', np.mean(pred == y) * 100)
return pred
该函数非常简单,并且在概念上重新追溯前向传播路径。在将 1 的偏置列应用于 X 后,a2 加载 X 乘以 θ1 的 sigmoid 值,并且只有在将 1 的偏置列添加到 a2 后,a3 才加载 X 乘以 θ2 的 sigmoid 值。参数 pred(预测)等于 a3 的最大值。最后,函数返回 pred 和 y 向量之间成功结果匹配的百分比。
在运行代码之前,上传测试版本的相同数据集:
# LOAD TESTING DATASET
with open('Alz.original.50X50.testing.dataset.pickle', 'rb') as handle:
ALZ = pickle.load(handle)
X = np.stack(ALZ['X'])
y = np.int64(ALZ['y'])
要运行 testing,请调用它,并指定两个训练好的 θ 向量、X 和 Y 作为参数:
pred = testing(Th1_trained, Th2_trained, X, y)
该神经网络架构在阿尔茨海默 MRI 数据集上的准确率为 95%
看到神经网络的实际运行效果将非常棒,展示一些具体结果。以下函数表示 plotSampleRandomly 的修改版,即我们用来随机显示数据集中 MRI 图像的函数,适合我们。它接受向量 X 和 y 作为参数,还有在测试阶段创建的向量 pred:
'''
plotSamplesRandomlyWithPrediction
Function for visualizing fifty randomly picked images with their prediction.
'''
def plotSamplesRandomlyWithPrediction(X, y, pred):
from random import randint
from matplotlib import pyplot as plt
# create a list of randomly picked indexes.
# the function randint creates the list, picking numbers in a
# range 0-Xn, which is the length of X
randomSelect = [randint(0, len(X)) for i in range(0, 51)]
# reshape all the pictures on the n X n pixels,
w, h =int(np.sqrt(X.shape[1])), int(np.sqrt(X.shape[1]))
fig=plt.figure(figsize=(int(np.sqrt(X.shape[1])), int(np.sqrt(X.shape[1]))))
# Define a grid of 10 X 10 for the big plot.
columns = 10
rows = 10
# The for loop
for i in range(1, 51):
# create the 2-dimensional picture
image = X[randomSelect[i]].reshape(w,h)
ax = fig.add_subplot(rows, columns, i)
# create a title for each pictures, containing #index and label
#title = "#"+str(randomSelect[i])+"; "+"y="+str(y[randomSelect[i]])
title = "#"+str(randomSelect[i])+"; "+"y:"+str(y[randomSelect[i]])+"; "+"p:"+str(pred[randomSelect[i]])
# set the title font size
ax.set_title(title, fontsize=np.int(np.sqrt(X.shape[1])/2))
# don't display the axis
ax.set_axis_off()
# plot the image in grayscale
plt.imshow(image, cmap='gray')
plt.show()
并按如下方式运行函数:
plotSamplesRandomlyWithPrediction(X, y, pred)
plotSamplesRandomlyWithPrediction 的一个可能输出如下:
图 12. 可视化原始阿尔茨海默数据集中随机挑选的预测结果。图像来源于 www.kaggle.com/datasets/uraninjo/augmented-alzheimer-mri-dataset
结论
神经网络是分类复杂图像的绝佳解决方案,尤其是当这些图像来自神经成像时。在其他专门讨论类似问题的文章中,我们看到对比度较差的图像可能在分类中遇到困难。Kaggle 提供的增强型阿尔茨海默病 MRI 数据集展示了一些优势,因为每张图像的对比度良好。然而,各病理类别特征的模式多样性所带来的复杂性很高,并且仅在偶尔的情况下才能一眼识别。
这篇文章旨在确定一种计算快速且相对高效的分类和模式预测技术,测试准确率约为 ~ 95%。此类工具为快速诊断提供了新的机会,并且在病理学中提供了一种巧妙的方法来提取关键特征。
这篇文章再次提供了详细解释构建和训练多层神经网络机制的机会。揭示了一些关于神经网络在逻辑门中的计算过程及其与逻辑回归的强相关性的直觉,展示了神经网络实际上是一个超级逻辑回归模型。
扩展文章中描述的代码可能会增加隐藏层的数量。由于编辑原因,我没有分享这些代码扩展,增加此特定应用中的隐藏层不会提高准确性百分比;相反,会使其变得更差。无论如何,有兴趣的人可以获得两层和三层隐藏层的代码。
参考文献
-
来自 Kaggle 的增强型阿尔茨海默病 MRI 数据集采用 GNU 较小公共许可证。它代表了 Kaggle 阿尔茨海默病数据集(4 类图像) 的一个分支,采用 开放数据公共开放数据库许可证(ODbL)v1.0
-
卢卡·扎马塔罗,一个或多个变量的线性回归,《数据科学前沿》,2019
-
卢卡·扎马塔罗,通过一对多检测黑色素瘤,《数据科学前沿》,2020
-
卢卡·扎马塔罗,用于图像识别的 Python 中的一对多逻辑回归,《数据科学前沿》,2022
-
克里斯·阿尔邦,Python 机器学习烹饪书,O’Reilly,ISBN-13:978–1491989388。
-
Aurélien Géron, 《动手学机器学习:使用 Scikit-Learn、Keras 和 TensorFlow 构建智能系统的概念、工具和技术》 第 2 版,O’Reilly,ISBN-10: 1492032646
多层感知器的解释与说明
原文:
towardsdatascience.com/multi-layer-perceptrons-8d76972afa2b
理解神经网络的第一个完全功能模型
·发表于 Towards Data Science ·13 分钟阅读·2023 年 4 月 2 日
–
在 上一篇文章 中,我们讨论了感知器作为最早的神经网络模型之一。正如我们所见,单个感知器在计算能力上有限,因为它们只能解决线性可分的问题。
在本文中,我们将讨论多层感知器(MLP),这些网络由多个感知器层组成,比单层感知器强大得多。我们将探讨这些网络如何运作,以及如何利用它们解决复杂任务,例如图像分类。
定义与符号
多层感知器(MLP)是一个至少有三层的神经网络:输入层、隐藏层和输出层。每一层都处理其前一层的输出:
MLP 架构
我们将使用以下符号:
-
aᵢˡ 是层 l 中神经元 i 的激活值(输出)
-
wᵢⱼˡ 是从层 l-1 中神经元 j 到层 l 中神经元 i 的连接权重
-
bᵢˡ 是层 l 中神经元 i 的偏置项
输入层和输出层之间的中间层称为 隐藏层,因为它们在网络之外不可见(它们构成了网络的“内部大脑”)。
输入层通常不算作网络中的层数。例如,一个 3 层网络有一个输入层、两个隐藏层和一个输出层。
前向传播
前向传播是将输入数据逐层通过网络的过程,直到生成输出。
在前向传播阶段,神经元的激活值计算类似于单个感知器的激活值计算方式。
例如,让我们看一下第l层的神经元 i。该神经元的激活值是通过两个步骤计算得出的:
- 我们首先计算神经元的净输入,作为其输入的加权和加上偏置:
第 l 层的神经元 i 的净输入
2. 我们现在对净输入应用激活函数以获得神经元的激活值:
第 l 层神经元 i 的激活值
根据定义,输入层神经元的激活值等于当前呈现给网络的示例的特征值,即,
输入神经元的激活值
其中 m 是数据集中的特征数量。
向量化形式
为了提高计算效率(特别是在使用像 NumPy 这样的数值库时),我们通常使用上述方程的向量化形式。
我们首先定义向量 aˡ 为包含第 l 层所有神经元激活值的向量,以及向量 bˡ 为包含第 l 层所有神经元偏置的向量。
我们还定义 Wˡ 为从第 l 层到第 l 层 - 1 的所有神经元的连接权重矩阵。例如,W¹₂₃ 是层 0(输入层)中神经元编号 2 与层 1(第一隐藏层)中神经元编号 3 之间连接的权重。
我们现在可以将前向传播方程写成向量形式。对于每一层 l,我们计算:
前向传播方程的向量化形式
解决 XOR 问题
多层感知器(MLP)相对于单层感知器的首次演示表明,它们能够解决 XOR 问题。XOR 问题是非线性可分的,因此单层感知器无法解决:
XOR 问题
然而,具有单层隐藏层的 MLP 可以轻松解决这个问题:
解决 XOR 问题的多层感知器(MLP)。偏置项写在节点内部。
让我们分析一下这个 MLP 是如何工作的。这个 MLP 除了两个输入神经元外,还有三个隐藏神经元和一个输出神经元。我们在这里假设所有的神经元都使用阶跃激活函数(即,对于所有非负输入,函数值为 1,而对于所有负输入,函数值为 0)。
顶层隐藏神经元仅与第一个输入 x₁ 连接,连接权重为 1,且有一个偏置 -1。因此,这个神经元仅在 x₁ = 1 时激活(此时其净输入为 1 × 1 + (-1) = 0,f(0) = 1,其中 f 是阶跃函数)。
中间隐藏神经元与两个输入相连,连接权重为 1,且有一个偏置 -2。因此,这个神经元仅在两个输入都为 1 时激活。
底部隐藏神经元仅与第二输入 x₂ 连接,连接权重为 1,且偏置为 -1。因此,这个神经元仅在 x₂ = 1 时被激活。
输出神经元与顶部和底部隐藏神经元的权重为 1,且与中间隐藏神经元的权重为 -2,偏置为 -1。因此,它仅在顶部或底部隐藏神经元激活时被激活,而在两者同时激活时不被激活。换句话说,它仅在 x₁ = 1 或 x₂ = 1 时被激活,而在两个输入都为 1 时不会被激活,这正是我们对 XOR 函数输出的预期。
例如,计算这个多层感知机(MLP)在输入 x₁ = 1 和 x₂ = 0 时的前向传播。此时隐藏神经元的激活情况是:
x1 = 1 和 x2 = 0 时隐藏神经元的激活情况
我们可以看到在这种情况下只有顶部的隐藏神经元被激活。
输出神经元的激活情况是:
MLP 在 x1 = 1 和 x2 = 0 时的输出
在这种情况下,输出神经元被激活,这正是我们对输入 x₁ = 1 和 x₂ = 0 时 XOR 输出的预期。
验证你是否理解 MLP 如何计算 XOR 函数的其他三个情况!
MLP 构建练习
作为另一个例子,考虑以下数据集,其中包含来自三个不同类别的点:
构建一个 MLP,正确分类数据集中所有点。
提示:使用隐藏神经元来识别三个分类区域。
解决方案可以在本文底部找到。
通用逼近定理
关于 MLP 的一个显著事实是它们可以计算任何任意的函数(尽管网络中的每个神经元计算的是非常简单的函数,如阶跃函数)。
通用逼近定理指出,一个具有足够数量神经元的单隐层 MLP 可以任意精确地逼近任何连续的输入函数。具有两个隐层的 MLP 甚至可以逼近不连续的函数。这意味着即使是非常简单的网络架构也可以非常强大。
不幸的是,定理的证明是非构造性的,即它没有告诉我们如何构建一个网络来计算特定的函数,只是展示了这样的网络存在。
MLP 中的学习:反向传播
尽管 MLP 已被证明在计算上非常强大,但很长一段时间内还不清楚如何在特定的数据集上训练它们。虽然单层感知器有一个简单的权重更新规则,但不清楚如何将此规则应用于隐藏层的权重,因为这些权重不会直接影响网络的输出(因此也不会直接影响训练损失)。
当 1986 年 Rumelhart 等人引入其突破性的反向传播算法用于训练 MLP 时,AI 社区花费了超过 30 年的时间才解决了这个问题。
反向传播的主要思想是首先计算网络误差函数相对于每个权重的梯度,然后使用梯度下降来最小化误差。之所以称之为反向传播,是因为我们利用导数链式法则将误差的梯度从输出层传播回输入层。
反向传播算法在 这篇文章 中有详细解释。
激活函数
在单层感知器中,我们使用了步进函数或符号函数作为神经元的激活函数。这些函数的问题在于它们的梯度几乎为 0(因为它们在 x > 0 和 x < 0 时等于常数值)。这意味着我们无法在梯度下降中使用它们来找到网络的最小误差。
因此,在 MLP 中我们需要使用其他激活函数。这些函数应该既可微分又是非线性的(如果 MLP 中所有神经元使用线性激活函数,则 MLP 的行为类似于单层感知器)。
对于隐藏层,最常见的三种激活函数是:
- Sigmoid 函数
2. 双曲正切函数
3. ReLU(修正线性单元)函数
输出层的激活函数取决于网络试图解决的问题:
-
对于回归问题,我们使用恒等函数 f(x) = x。
-
对于二分类问题,我们使用 Sigmoid 函数(如上所示)。
-
对于多类分类问题,我们使用 softmax 函数,它将 k 个实数的向量转换为 k 种可能结果的概率分布:
Softmax 函数
在这篇文章中深入解释了为什么我们在多类问题中使用 Softmax 函数:
理解 Softmax 回归背后的数学原理以及如何使用它来解决图像分类任务
towardsdatascience.com
Scikit-Learn 中的 MLP
Scikit-Learn 提供了两个实现 MLP 的类,位于 sklearn.neural_network 模块中:
-
MLPClassifier 用于分类问题。
-
MLPRegressor 用于回归问题。
这些类中的重要超参数有:
-
hidden_layer_sizes — 定义每个隐藏层中神经元数量的元组。默认值是 (100,),即一个包含 100 个神经元的隐藏层。对于许多问题,使用一两个隐藏层应该足够。对于更复杂的问题,你可以逐渐增加隐藏层的数量,直到网络开始过拟合训练集。
-
activation — 在隐藏层中使用的激活函数。选项有 ‘identity’,‘logistic’,‘tanh’,和 ‘relu’(默认)。
-
solver — 用于权重优化的求解器。默认值是 ‘adam’,它在大多数数据集上表现良好。各种优化器的行为将在未来的文章中解释。
-
alpha — L2 正则化系数(默认为 0.0001)
-
batch_size — 用于训练的迷你批次的大小(默认为 200)。
-
learning_rate — 权重更新的学习率调度(默认为 ‘constant’)。
-
learning_rate_init — 使用的初始学习率(默认为 0.001)。
-
early_stopping — 是否在验证得分没有改善时停止训练(默认为 False)。
-
validation_fraction — 从训练集中留出用于验证的比例(默认为 0.1)。
我们通常使用网格搜索和交叉验证来调整这些超参数。
在 MNIST 上训练 MLP
例如,让我们在 MNIST 数据集 上训练一个 MLP,这是一个广泛用于图像分类任务的数据集。
数据集包含 60,000 张训练图像和 10,000 张测试图像,每张图像为 28 × 28 像素,通常用一个包含 784 个数字的向量表示,范围在 [0, 255] 之间。任务是将这些图像分类到十个数字(0-9)之一。
我们首先使用 fetch_openml() 函数获取 MNIST 数据集:
from sklearn.datasets import fetch_openml
X, y = fetch_openml('mnist_784', return_X_y=True, as_frame=False)
as_frame 参数指定我们希望以 NumPy 数组而不是 DataFrame 的形式获取数据和标签(这个参数的默认值在 Scikit-Learn 0.24 中从 False 改为 ‘auto’)。
让我们检查 X 的形状:
print(X.shape)
(70000, 784)
也就是说,X 由 70,000 个 784 像素的平面向量组成。
让我们显示数据集中的前 50 个数字:
fig, axes = plt.subplots(5, 10, figsize=(10, 5))
i = 0
for ax in axes.flat:
ax.imshow(X[i].reshape(28, 28), cmap='binary')
ax.axis('off')
i += 1
MNIST 数据集中的前 50 个数字
让我们检查每个数字的样本数量:
np.unique(y, return_counts=True)
(array(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], dtype=object),
array([6903, 7877, 6990, 7141, 6824, 6313, 6876, 7293, 6825, 6958],
dtype=int64))
数据集在 10 个类别之间比较均衡。
我们现在将输入缩放到 [0, 1] 范围内,而不是 [0, 255]:
X = X / 255
特征缩放使神经网络的训练更快,并防止它们陷入局部最优解。
我们现在将数据分为训练集和测试集。请注意,MNIST 中前 60,000 张图像已被指定用于训练,因此我们可以通过简单的切片操作来进行拆分:
train_size = 60000
X_train, y_train = X[:train_size], y[:train_size]
X_test, y_test = X[train_size:], y[train_size:]
我们现在创建一个具有 300 个神经元的单隐藏层 MLP 分类器。我们将保持所有其他超参数的默认值,除了early_stopping,我们将其更改为 True。我们还将设置 verbose=True,以便跟踪训练进度:
from sklearn.neural_network import MLPClassifier
mlp = MLPClassifier(hidden_layer_sizes=(300,), early_stopping=True,
verbose=True)
让我们将分类器拟合到训练集上:
mlp.fit(X_train, y_train)
训练期间得到的输出是:
Iteration 1, loss = 0.35415292
Validation score: 0.950167
Iteration 2, loss = 0.15504686
Validation score: 0.964833
Iteration 3, loss = 0.10840875
Validation score: 0.969833
Iteration 4, loss = 0.08041958
Validation score: 0.972333
Iteration 5, loss = 0.06253450
Validation score: 0.973167
...
Iteration 31, loss = 0.00285821
Validation score: 0.980500
Validation score did not improve more than tol=0.000100 for 10 consecutive epochs. Stopping.
训练在 31 次迭代后停止,因为在前 10 次迭代中验证分数没有改善。
让我们检查一下 MLP 在训练集和测试集上的准确性:
print('Accuracy on training set:', mlp.score(X_train, y_train))
print('Accuracy on test set:', mlp.score(X_test, y_test))
Accuracy on training set: 0.998
Accuracy on test set: 0.9795
这些是很好的结果,但具有更复杂架构的网络,如卷积神经网络(CNNs),可以在这个数据集上获得更好的结果(在测试集上的准确率高达 99.91%!)。你可以在这里找到关于 MNIST 的最先进结果及相关论文的链接。
为了更好地理解我们模型的错误,让我们显示其混淆矩阵:
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
y_pred = mlp.predict(X_test)
cm = confusion_matrix(y_test, y_pred)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=mlp.classes_)
disp.plot(cmap='Blues')
测试集上的混淆矩阵
我们可以看到,模型的主要混淆发生在数字 4⇔9、7⇔9 和 2⇔8 之间。这是有道理的,因为这些数字在手写时经常彼此相似。为了帮助我们的模型区分这些数字,我们可以增加这些数字的样本(例如,通过数据增强)或从图像中提取额外的特征(例如,数字中的闭合环数)。
可视化 MLP 权重
尽管神经网络通常被认为是“黑箱”模型,但在由一到两个隐藏层组成的简单网络中,我们可以可视化学到的权重,并偶尔对这些网络如何在内部工作有所了解。
例如,我们可以绘制 MLP 分类器的输入层和隐藏层之间的权重。权重矩阵的形状为 (784, 300),并存储在一个名为 mlp.coefs_[0] 的变量中:
print(mlp.coefs_[0].shape)
(784, 300)
这个矩阵的 i 列表示输入到隐藏神经元 i 的权重。我们可以将这一列显示为一个 28 × 28 像素的图像,以检查哪些输入神经元对该神经元的激活有较强的影响。
以下图展示了前 20 个隐藏神经元的权重:
fig, axes = plt.subplots(4, 5)
for coef, ax in zip(mlp.coefs_[0].T, axes.flat):
im = ax.imshow(coef.reshape(28, 28), cmap='gray')
ax.axis('off')
fig.colorbar(im, ax=axes.flat)
前 20 个隐藏神经元的权重
我们可以看到每个隐藏神经元关注图像的不同部分。
其他库中的 MLP
尽管 Scikit-Learn 中的 MLP 分类器易于使用,但在实际应用中,你更可能使用如 TensorFlow 或 PyTorch 等深度学习库来构建 MLP。这些库可以利用更快的 GPU 处理速度,还提供许多附加选项,如额外的激活函数和优化器。你可以在这篇文章中找到如何使用这些库的示例。
MLP 构建练习的解决方案
以下 MLP 正确分类了数据集中的所有点:
MLP 用于解决分类问题。偏置项被写在节点内部。
解释:
左侧隐藏神经元只有在x₁ ≤ 3 时才会激活,中间隐藏神经元只有在x₂ ≥ 4 时才会激活,右侧隐藏神经元只有在x₂ ≤ 0 时才会激活。
左侧输出神经元对左侧和中间隐藏神经元执行 OR 操作,因此只有在x₁ ≤ 3 OR x₂ ≥ 4 时才会激活,即只有当点是蓝色时。
中间输出神经元对所有隐藏神经元执行 NOR(非 OR)操作,因此只有在 NOT(x₁ ≤ 3 OR x₂ ≥ 4 OR x₂ ≤ 0)时才会激活。换句话说,只有当x₁ > 3 AND 0 < x₂ < 4 时,它才会激活,即只有当点是红色时。
只有当右侧隐藏神经元激活时,右侧输出神经元才会激活,即只有当x₂ ≤ 0 时,才会发生这种情况,这仅对紫色点有效。
最终说明
你可以在我的 GitHub 上找到本文的代码示例:github.com/roiyeho/medium/tree/main/mlp
除非另有说明,否则所有图片均由作者提供。
MNIST 数据集信息:
-
引用: 邓丽君,2012。用于机器学习研究的手写数字图像的 MNIST 数据库。IEEE 信号处理杂志,29(6),第 141–142 页。
-
许可证: Yann LeCun 和 Corinna Cortes 拥有 MNIST 数据集的版权,该数据集根据知识共享署名-相同方式共享 4.0 国际许可证(CC BY-SA)提供。
感谢阅读!