批量操作 (Batched Operations)
批量射线 - 线段交点检测
接下来,实现射线 - 线段交点检测的批处理版本。他有多条射线和多条线段,并为每条射线返回一个 bool 值,表示是否有任何线段和该射线相交.
注意,在批量处理版本中,我们不希望 solve 方程时仅仅因为某些方程无解就抛出异常,这些无解的方程应该直接返回 False.
Tip - 省略号
你可以在索引切片表达式中使用省略号 ...
来避免重复使用 :
, 并且可以兼容维度数不同的输入张量.
例如, x[..., 0]
和 x[;, :, 0]
在 x
是三维张量的前提下是等价的。若 x
是四维张量那和 x[:, :, :, 0]
是等价的.
Tip - 张量中按元素逻辑运算
对于常规的 bool 值,关键字 and
, or
和 not
可以用于逻辑运算,而 &
, |
和 ~
会对输入数字按位执行与或非运算。例如 0b10001 | 0b11000
结果是 0b11001
, 即十进制下的 25
.
不幸的是,Python 中不允许类重载关键字。因此如果 x
和 y
的类型为 torch.Tensor
, 那么 x and y
可能不会执行你想要的操作,即按元素执行 x[i] and y[i]
运算。实际上他会尝试把 x
强制转换为 bool 值,这会报错.
作为一种解决方法,PyTorch (和 NumPy) 选择重载位运算符,但让他们实际上等效于按元素进行逻辑运算,因为通常你不会让矩阵中的值作位运算。因此想要得到 x[i] and y[i]
结果的正确表达方式应该是 x & y
.
在涉及逻辑运算时还有个问题是运算优先级。例如 v >= 0 & v <= 1
实际上计算顺序是 (v >= (0 & v)) <= 1
, 因为 &
有更高的运算优先级。如果出现了有疑问的结果请使用括号强制让式子按照你的想法运算: (v >= 0) & (v <= 1)
.
Tip - einops
Einops 是一个很有用的库,明天我们会对其进行更深入的研究。目前你唯一需要了解的重要的函数就是 einops.repeat
. 这个函数将将张量和字符串作为参数,并返回一个沿着给定维度重复的新张量。例如,以下代码展示了如何沿着最后一个维度重复二维张量:
x = t.randn(2, 3)
x_repeated = einops.repeat(x, 'a b -> a b c', c=4)
assert x_repeated.shape == (2, 3, 4)
for c in range(4):
t.testing.assert_close(x, x_repeated[:, :, c])
2
3
4
5
6
(函数 t.testing.assert_close
会检查两个张量形状是否相同,且所有元素值是否相同或仅有微小的误差.)
Tip - 逻辑约简 (Logical Reductions)
在原版的 Python 中,如果你有一个列表的列表并且想知道每一行的所有元素是否均为 True, 你可以使用列表解析式例如 [any(row) for row in rows]
. 在 PyTorch 中执行此类似操作的有效方法是使用 torch.any()
或者等效的张量的 .any()
方法,他的参数是需要减少的维度。类似的,还有 torch.all()
和 .all()
方法。这两种方法都要接受一个 dim
参数表示需要减少的维度.
Aside - 张量方法
PyTorch(和Numpy)中的大多数函数,例如torch.any(tensor, ...)
(即将张量作为第一个参数的函数)都有一个等效的张量方法tensor.any(...)
.在课程的后面我们会看到更多此类函数的例子. Tip - 广播
广播是当你对两个张量执行操作时可能发生的情况,其中一个张量尺寸较小,但是可以沿着尺寸较大的张量维度复制来让操作可以进行。下面是一个示例 (其中 B
沿着 A
的第一个维度复制):
A = t.randn(2, 3)
B = t.randn(3)
AplusB = A + B
assert AplusB.shape == (2, 3)
for i in range(2):
t.testing.assert_close(AplusB[i], A[i] + B)
2
3
4
5
6
7
广播的明确含义有点难讲明白,我们会在课程后面详细讨论。如果你想查看完整内容可以点击展开下面内容。现在你需要了解的最重要的一点是 - 维度 被附加到了尺寸较小的张量 B
的开头,并沿着这些尺寸复制直到其形状和尺寸较大的张量 A
相匹配.
Aside - 广播的细节
如果你尝试去广播张量A
和B
,那么会发生下面的事情:- 如果两个张量尺寸相同,或者不同的尺寸对应轴的大小为1,那么他们是兼容的(对后一种情况,我们将沿着该维度重复大小为1的张量,直到它与较大维度的尺寸相同).
- 如果两个张量不兼容则无法广播.
B
左边被填充成了(1, 3)
,接下来沿着第一个维度复制张量使得最终形状成为(2, 3)
,这样就与A
的形状一致可以相加.另一方面,如果
B
的形状是(2,)
那么就无法广播,因为新维度无法被添加到张量右边.对上面所有 Tips 的省流
...
来避免重复输入:
.&
,|
和~
来在张量中进行按元素逻辑运算.torch.any()
和any.()
来进行逻辑约简(通过使用参数dim
,你可以只在一个维度上执行这个操作).A
和B
(B
的维度数更少),这个操作只有在B
的形状和A
的最后几个维度形状相同时才能进行(在这种情况下,B
将沿着A
前面几个维度的形状复制).Exercise - 完成 intersect_rays_1d
Importance: 🔵🔵🔵🔵⚪
你应该花最多25-30分钟在这个练习上.
这是今天最有难度的练习,在难度上跨度可能是最大的,请不要过早的气馁.
考虑到上面所有的技巧,现在你应该能够完成 intersect_rays_1d
函数了,即 intersect_ray_1d
的批处理版本。该函数对于每一条射线将返回一个 bool 值表示射线是否和任何线段有交点.
def intersect_rays_1d(rays: Float[Tensor, "nrays 2 3"], segments: Float[Tensor, "nsegments 2 3"]) -> Bool[Tensor, "nrays"]:
'''
For each ray, return True if it intersects any segment.
'''
pass
tests.test_intersect_rays_1d(intersect_rays_1d)
tests.test_intersect_rays_1d_special_case(intersect_rays_1d)
2
3
4
5
6
7
8
9
救一救!我不知道该怎么不用循环完成这个函数.
首先,rays.shape == (NR, 2, 3)
再segments.shape == (NS, 2, 3)
.试试在这两个张量上用用einops.repeat
,这样可以让他们的形状都变成(NR, NS, 2, 3),接下来你就可以造矩阵并批量solve了. 救一救!我不知道怎么处理行列式为0的情况.
你可以用t.linalg.det
计算矩阵或批量矩阵的行列式.(陷阱: 行列式不会完全等于0,但是你可以检查它是否十分接近0,例如det.abs() < 1e-6
).这会返回一个bool值掩码矩阵告诉你哪些矩阵是奇异的.你可以把所有奇异矩阵设成单位矩阵(这可以避免报错),然后在最后你可以再次使用bool值掩码矩阵来将那些奇异矩阵对应的相交情况设置为
False
. 救一救!我不知道怎么处理行列式为0的矩阵的情况了.
在创建矩阵方程后,你应当有一批形状为(NR, NS, 2, 2)
的矩阵.即对于每个mat[i, j, :, :]
都类似与上一页的等式左边部分.调用
t.linalg.det(mat)
将返回一个形状为(NR, NS)
的数组,其中包含每个矩阵的行列式值.你可以用这个值来构造奇异矩阵的掩码.(陷阱:行列式解出来不会完全为0,但是你可以检查它是否十分接近0,例如det.abs() < 1e-6
).使用该掩码作为
mat
的索引将返回形状为(x, 2, 2)
的数组,其中第0维会索引到所有的奇异矩阵.正如我们之前在广播部分讨论的内容,这意味着我们可以使用广播将这些奇异矩阵设置为单位矩阵.mat[is_singular] = t.eye(2)
Solution
def intersect_rays_1d(rays: Float[Tensor, "nrays 2 3"], segments: Float[Tensor, "nsegments 2 3"]) -> Bool[Tensor, "nrays"]:
'''
For each ray, return True if it intersects any segment.
'''
# SOLUTION
NR = rays.size(0)
NS = segments.size(0)
# Get just the x and y coordinates
rays = rays[..., :2]
segments = segments[..., :2]
# Repeat rays and segments so that we can compuate the intersection of every (ray, segment) pair
rays = einops.repeat(rays, "nrays p d -> nrays nsegments p d", nsegments=NS)
segments = einops.repeat(segments, "nsegments p d -> nrays nsegments p d", nrays=NR)
# Each element of `rays` is [[Ox, Oy], [Dx, Dy]]
O = rays[:, :, 0]
D = rays[:, :, 1]
assert O.shape == (NR, NS, 2)
# Each element of `segments` is [[L1x, L1y], [L2x, L2y]]
L_1 = segments[:, :, 0]
L_2 = segments[:, :, 1]
assert L_1.shape == (NR, NS, 2)
# Define matrix on left hand side of equation
mat = t.stack([D, L_1 - L_2], dim=-1)
# Get boolean of where matrix is singular, and replace it with the identity in these positions
dets = t.linalg.det(mat)
is_singular = dets.abs() < 1e-8
assert is_singular.shape == (NR, NS)
mat[is_singular] = t.eye(2)
# Define vector on the right hand side of equation
vec = L_1 - O
# Solve equation, get results
sol = t.linalg.solve(mat, vec)
u = sol[..., 0]
v = sol[..., 1]
# Return boolean of (matrix is nonsingular, and solution is in correct range implying intersection)
return ((u >= 0) & (v >= 0) & (v <= 1) & ~is_singular).any(dim=-1)
使用 GPT 理解代码
请注意,目前 LLM 领域发展极快,所以这个部分的内容可能在某个时刻之后就过时了!(比如现在 openai 就不开放 GPT-3.5 了,免费用户只剩下 GPT-4o 可以选择。但是下面有关 GPT-3.5 的内容对于 GPT-4o 还是适用的.) 现在让我们看一个使用 GPT 辅助 coding 的简单示例: 使用 GPT 来理解代码。建议你阅读 Siddharth Hiregowdara 最近发表的 Lesswrong 贴子,这里详细讲述了他使用 GPT 辅助理解代码的流程。这在 GPT-4 上效果最好,但是在 GPT-3.5 上对于简单的问题也同样有效 (请参考下面的内容).
首先,你应该搞到一个能用 GPT 的账号。接下来,试试让 GPT-3.5/4 解释上面的函数。你可以用类似下面的 prompt 来提问:
Explain this Python function, line by line. You should break up your explanation by inserting sections of the code.
def intersect_rays_1d(rays: Float[Tensor, "nrays 2 3"], segments: Float[Tensor, "nsegments 2 3"]) -> Bool[Tensor, "nrays"]:
NR = rays.size(0)
NS = segments.size(0)
rays = rays[..., :2]
...
2
3
4
5
6
7
我发现删掉注释通常会更有帮助,因为这样 GPT 会用自己的话回答,而不仅仅是重复注释的内容 (而且注释有时候会让他感到困惑).
一旦你得到了回答,你可以考虑问以下的问题:
- Can you suggest ways to improve the code?(你能给我改进这个代码的建议吗?)
- GPT-4 建议使用更长的文档字符串和更有描述性的变量名称.
- Can you explain why the line
mat[is_singular] = t.eye(2)
works?(你可以解释一下为什么mat[is_singular] = t.eye(2)
这行是如何工作的吗?)- GPT-4 给了我一个正确且非常详细的解释,涉及广播和张量形状.
使用 GPT 算作弊吗?如果你在遇到问题的第一反应是找 GPT 而不是尝试自己理解代码,那可能确实是作弊。但是这里要指出简单模式和困难模式的区别。某些情况下,在继续下一步之前对问题进行一段时间的思考是很有价值的,因为这种深思熟虑可以让你成为一名更好的科研者或工程师 (例如,当你在对 transformer 进行机理解释时思考一个环节是如何工作的假设时,或者在实现某些 RL 算法时思考哪种数据结构最适合你的用例). 但还有一些情况 (比如目前这种情况), 你可以从 GPT 速通中获得更多知识来了解一些代码或概念,并将你的理解应用到后续练习中。找到平衡点很重要!
什么时候用 GPT-3.5, 什么时候用 GPT-4
GPT-3.5 和 4 在不同情况下各有优缺点.GPT-3.5 在速度上比起 GPT-4 有很大优势,并且在简单问题上同样表现良好。如果你想让 GPT 完成的任务是用 Copilot 就能干的,那你最好使用 GPT-3.5 而不是 GPT-4.
另一方面,GPT-4 在生成连贯代码方面更有优势 (尽管我们不希望你在这个阶段就大量使用 GPT-4 生成代码), 并且在总体上更擅长用更少的 prompt 工程技巧就能响应复杂任务.
使用 GPT 的额外笔记 (来自 Joseph Bloom)
- ChatGPT 过于友好了。如果你给他糟糕的代码,他不会告诉你这是一坨,所以你应该鼓励他提供反馈和 / 或想你展示优秀代码的示例。特别是对于初学者使用 GPT 时,重要的是知道如何让他对你严格一点.
- GPT 非常擅长写测试代码 (让他给你的函数写测试代码通常比直接问他代码是否正确要好), 重构代码 (识别代码中的重复任务并提取他们) 和变量命名。这些都值得你去尝试几次看看他们有多有用.
- GPT-4 可以很好的处理一整个模块和脚本,所以请毫不犹豫的上传他们。当你开始在 GitHub 上管理存储库时,请使用跟踪文件,这样当你复制粘贴编辑后的代码时,所有的更改都会突出显示.(VSCode 会在 Python 文件的行号旁边用蓝色小条突出显示更改).
这是一些你可以玩玩看的内容:
- 让 GPT 给你写函数的测试代码。你可以给出更具体的说明 (例如要求他使用 / 不使用
unittests
库,或者打印更多有信息的报错提示). - 问 GPT 如何重构上面的函数.(当我这么做的时候,它建议我将函数拆分为子函数,这些子函数执行 "计算交点" 的小任务.)
二维射线 (2D rays)
现在我们将使用 z 维度并假定射线从原点出发同时射到 y 维度和 z 维度中.
Importance: 🔵🔵⚪⚪⚪
你应该花最多10-15分钟在这个练习上.
仿照 make_rays_1d
完成 make_rays_2d
. 结果可视化后应该看起来像一个金字塔,原点在顶点的位置.
def make_rays_2d(num_pixels_y: int, num_pixels_z: int, y_limit: float, z_limit: float) -> Float[t.Tensor, "nrays 2 3"]:
'''
num_pixels_y: The number of pixels in the y dimension
num_pixels_z: The number of pixels in the z dimension
y_limit: At x=1, the rays should extend from -y_limit to +y_limit, inclusive of both.
z_limit: At x=1, the rays should extend from -z_limit to +z_limit, inclusive of both.
Returns: shape (num_rays=num_pixels_y * num_pixels_z, num_points=2, num_dims=3).
'''
pass
rays_2d = make_rays_2d(10, 10, 0.3, 0.3)
render_lines_with_plotly(rays_2d)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
救一救!我不知道怎么完成这个函数.
不要立刻把它写成函数,最有效的做法是把代码一行行用REPL(Read-eval-print loop,直接在终端输入python
看到的界面)测试,以验证它是否符合你的预期,然后再继续.你可以用
torch.stack
构建输出张量,也可以直接将输出张量初始化为最终大小,然后赋值给rays[:, 1, 1] = ...
这样的切片.两种都是很好的实现方法.每个y坐标需要一条射线和它对应的z坐标-换句话说,这是一个外积.最优雅的方法是调用两次
einops.repeat
.你还可以通过组合调用unsqueeze
,expand
和reshape
来实现这个操作. Solution
def make_rays_2d(num_pixels_y: int, num_pixels_z: int, y_limit: float, z_limit: float) -> Float[t.Tensor, "nrays 2 3"]:
'''
num_pixels_y: The number of pixels in the y dimension
num_pixels_z: The number of pixels in the z dimension
y_limit: At x=1, the rays should extend from -y_limit to +y_limit, inclusive of both.
z_limit: At x=1, the rays should extend from -z_limit to +z_limit, inclusive of both.
Returns: shape (num_rays=num_pixels_y * num_pixels_z, num_points=2, num_dims=3).
'''
# SOLUTION
n_pixels = num_pixels_y * num_pixels_z
ygrid = t.linspace(-y_limit, y_limit, num_pixels_y)
zgrid = t.linspace(-z_limit, z_limit, num_pixels_z)
rays = t.zeros((n_pixels, 2, 3), dtype=t.float32)
rays[:, 1, 0] = 1
rays[:, 1, 1] = einops.repeat(ygrid, "y -> (y z)", z=num_pixels_z)
rays[:, 1, 2] = einops.repeat(zgrid, "z -> (y z)", y=num_pixels_y)
return rays