def take_one_elem_per_col(x: Tensor) -> Tensor:
"""
Construct a new tensor by picking out one element from each column of the
input tensor as described below.
构造一个新tensor通过选出输入tensor每列中如下描述的一个元素
The input tensor should not be modified, and you should only access the
data of the input tensor using a single indexing operation.
输入向量不应被修改,并且你只能访问输入向量的数据使用单个索引操作符
Args:
x: A tensor of shape (M, N) with M >= 4 and N >= 3.
Returns:
A tensor y of shape (3,) such that:
- The first element of y is the second element of the first column of x
- The second element of y is the first element of the second column of x
- The third element of y is the fourth element of the third column of x
"""
##########################################################################
# TODO: Implement this function #
##########################################################################
# Replace "pass" statement with your code
idx0 = torch.tensor([1,0,3])
idx1 = torch.arange(0,3)
y = x[idx0,idx1]
##########################################################################
# END OF YOUR CODE #
##########################################################################
return y
def make_one_hot(x: List[int]) -> Tensor:
"""
Construct a tensor of one-hot-vectors from a list of Python integers.
Your implementation should not use any Python loops (including
comprehensions).
Args:
x: A list of N integers
Returns:
y: Tensor of shape (N, C) and where C = 1 + max(x) is one more than the
max value in x. The nth row of y is a one-hot-vector representation
of x[n]; in other words, if x[n] = c then y[n, c] = 1; all other
elements of y are zeros. The dtype of y should be torch.float32.
"""
y = None
##########################################################################
# TODO: Implement this function #
##########################################################################
# Replace "pass" statement with your code
##########################################################################
# END OF YOUR CODE #
##########################################################################
return y
pytorch的深浅拷贝
非常好的问题!你已经掌握了 .view() 和 .clone() 的重要区别,接下来我们深入探讨 PyTorch 中哪些操作会 共享底层数据(即浅拷贝),哪些操作会 创建新的内存副本(即深拷贝)。
🔍 总览:常见操作是否共享内存(与原张量的数据是否共享)
操作 | 是否共享数据 | 说明 |
---|---|---|
.view() | ✅ 是 | reshape 操作,不复制数据,要求 tensor 是 contiguous 的。 |
.reshape() | ❓ 不确定 | 尽可能不复制数据,如果不能保证就会创建副本。 |
.clone() | ❌ 否 | 明确复制,分配新内存。 |
.detach() | ✅ 是 | 分离计算图但共享数据(除非 .clone())。 |
.detach().clone() | ❌ 否 | 常用于生成完全独立副本(如保存模型输出)。 |
slice / index | ✅ 是 | 返回的是 视图(view),共享数据。 |
x.T, x.transpose() | ✅ 是 | 转置也是视图操作,数据共享。 |
x.contiguous() | ❌ 否(若触发) | 若 tensor 非连续,会生成一个连续副本,否则返回原对象。 |
x.to(…) | ❌ 否 | 数据迁移(如 CPU→GPU)时一定分配新空间。 |
x.expand() | ✅ 是 | 不复制数据,使用 stride 技巧实现广播。 |
x.repeat() | ❌ 否 | 明确复制数据,内存翻倍。 |
.cpu() / .cuda() | ❌ 否 | 数据复制到新设备。 |
x.unbind() | ✅ 是 | 返回的子张量共享数据。 |
torch.chunk() | ✅ 是 | 默认返回视图,数据共享。 |
x.flatten() | ❓ 不一定 | 若可 flatten 为 view,则共享,否则复制。 |
torch.split() | ❓ 不保证 | 有些版本中为 view,有些为 copy。看版本实现。 |
🔬 代码实验:切片是否共享数据?
import torch
x = torch.tensor([1, 2, 3, 4])
y = x[1:3] # 切片是 view
y[0] = 100 # 改变 y,会影响 x
print(x) # tensor([ 1, 100, 3, 4])
🔬 代码实验:
.reshape()
是否共享?
x = torch.arange(6)
y = x.reshape(2, 3)
y[0, 0] = 99
print(x) # 可能变,也可能不变,取决于底层是否复制
🧠 建议记忆技巧:
-
🚦 View 类操作(浅拷贝):view, slice, transpose, detach, expand, T, chunk, unbind
-
💾 Copy 类操作(深拷贝):clone, repeat, to, .cpu(), .cuda(), 有时是 reshape, flatten, split
🧪 判断某操作是否共享内存的实用方法:
x = torch.tensor([1, 2, 3])
y = x[1:] # suspect it's a view
y[0] = 99
print(x) # 如果 x 变了,说明 y 是 view(共享内存)
KNN
2 loop
num_train = x_train.shape[0]
num_test = x_test.shape[0]
dists = x_train.new_zeros(num_train, num_test)
x_train_flat = x_train.view(num_train, -1)
x_test_flat = x_test.view(num_test, -1)
for i in range(num_train):
for j in range(num_test):
diff = x_train_flat[i] - x_test_flat[j] # shape: (D,)
dists[i, j] = torch.sum(diff ** 2) # squared Euclidean distance
return dists
1 loop
def compute_distances_one_loop(x_train: torch.Tensor, x_test: torch.Tensor):
num_train = x_train.shape[0]
num_test = x_test.shape[0]
dists = x_train.new_zeros(num_train, num_test)
# Flatten both train and test sets
x_train_flat = x_train.view(num_train, -1)
x_test_flat = x_test.view(num_test, -1)
for i in range(num_train):
# x_train_flat[i]: shape (D,)
# x_test_flat: shape (num_test, D)
diff = x_test_flat - x_train_flat[i] # broadcast: (num_test, D)
dists[i] = torch.sum(diff ** 2, dim=1) # (num_test,)
return dists
EECS 498-007/598-005 是密歇根大学的“Deep Learning for Computer Vision”课程,其作业之一要求实现基于 PyTorch 的 k-Nearest Neighbor (kNN) 分类器,代码通常在 knn.py
文件中完成。以下是对 knn.py
典型实现的详细讲解,基于课程的背景和提供的参考信息(如 GitHub 上的代码片段)。我将从功能、实现细节和代码结构三个方面进行分析,并确保逻辑清晰、易于理解。
1. 背景与目标
kNN 是一种简单但有效的监督学习分类算法,广泛用于图像分类任务(如 CIFAR-10 数据集)。在 EECS 498 的作业中,knn.py
的目标是:
- 实现 kNN 分类器:包括距离计算、标签预测和交叉验证。
- 优化性能:通过向量化(避免 Python 循环)提高代码效率。
- 支持 CIFAR-10 数据集:处理图像数据(形状为
(num_samples, C, H, W)
,例如(500, 3, 32, 32)
)。 - 使用 PyTorch:利用 PyTorch 的张量操作进行高效计算。
作业通常要求学生完成以下核心函数:
compute_distances_two_loops
:使用双重循环计算训练集和测试集之间的距离。compute_distances_one_loop
:优化为单重循环。compute_distances_no_loops
:完全向量化实现。predict_labels
:根据距离预测测试样本的标签。knn_cross_validate
:执行交叉验证以选择最佳的 k 值。knn_get_best_k
:从交叉验证结果中选择最佳 k。
2. 代码结构与核心函数实现
以下是 knn.py
的典型实现,结合参考资料(如 和)进行讲解。
2.1 导入与初始化
import torch
import statistics
def hello():
"""测试函数,确保 Google Colab 环境正确配置"""
print('Hello from knn.py!')
- 作用:导入 PyTorch 库,定义一个简单的
hello
函数用于环境测试。 - 注意:作业在 Google Colab 上运行,需确保 PyTorch 正确安装。
2.2 计算距离
kNN 的核心是计算测试样本与训练样本之间的距离(通常为平方欧几里得距离)。knn.py
提供了三种实现,逐步优化性能。
2.2.1 compute_distances_two_loops
def compute_distances_two_loops(x_train, x_test):
"""
使用双重循环计算训练集和测试集之间的平方欧几里得距离。
输入:
- x_train: 形状 (num_train, C, H, W) 或 (num_train, D),训练数据
- x_test: 形状 (num_test, C, H, W) 或 (num_test, D),测试数据
返回:
- dists: 形状 (num_train, num_test),距离矩阵
"""
num_train = x_train.shape[0]
num_test = x_test.shape[0]
dists = torch.zeros(num_train, num_test)
# 展平图像为向量
x_train_flat = x_train.view(num_train, -1)
x_test_flat = x_test.view(num_test, -1)
for i in range(num_train):
for j in range(num_test):
dists[i, j] = torch.sum((x_train_flat[i] - x_test_flat[j]) ** 2)
return dists
- 功能:对每一对训练样本和测试样本,计算平方欧几里得距离:。
- 实现细节:
- 使用
view
将图像展平(例如,(500, 3, 32, 32)
变为(500, 3072)
)。 - 双重循环逐个计算距离,存储在
dists
矩阵中。
- 使用
- 缺点:效率低下,时间复杂度为 O(\text{num_train} cdot \text{num_test} cdot D),不适合大规模数据集。
2.2.2 compute_distances_one_loop
def compute_distances_one_loop(x_train, x_test):
"""
使用单重循环计算距离。
"""
num_train = x_train.shape[0]
num_test = x_test.shape[0]
dists = torch.zeros(num_train, num_test)
x_train_flat = x_train.view(num_train, -1)
x_test_flat = x_test.view(num_test, -1)
for j in range(num_test):
dists[:, j] = torch.sum((x_train_flat - x_test_flat[j]) ** 2, dim=1)
return dists
- 改进:通过广播(broadcasting)消除内层循环,计算一个测试样本与所有训练样本的距离。
- 实现细节:
x_train_flat - x_test_flat[j]
利用 PyTorch 广播,生成形状为(num_train, D)
的差值矩阵。torch.sum(..., dim=1)
沿特征维度求和,得到每个训练样本的距离。
- 性能:时间复杂度降为 (O(\text{num_test} \cdot D)),但仍未完全优化。
2.2.3 compute_distances_no_loops
def compute_distances_no_loops(x_train, x_test):
"""
完全向量化计算距离。
"""
num_train = x_train.shape[0]
num_test = x_test.shape[0]
x_train_flat = x_train.view(num_train, -1)
x_test_flat = x_test.view(num_test, -1)
# 展开公式:||a - b||^2 = ||a||^2 + ||b||^2 - 2 * a @ b
train_norm = (x_train_flat ** 2).sum(dim=1).view(-1, 1)
test_norm = (x_test_flat ** 2).sum(dim=1).view(1, -1)
inner_prod = x_train_flat @ x_test_flat.t()
dists = train_norm + test_norm - 2 * inner_prod
return dists
- 优化:利用矩阵运算完全消除循环,基于公式: [ |a - b|^2 = |a|^2 + |b|^2 - 2 \cdot a \cdot b ]
- 实现细节:
- 计算训练集和测试集的平方范数:
train_norm
和test_norm
。 - 计算点积:
x_train_flat @ x_test_flat.t()
,形状为(num_train, num_test)
。 - 组合三项得到距离矩阵。
- 计算训练集和测试集的平方范数:
- 优势:时间复杂度为效率最高,适合 GPU 加速。
2.3 预测标签
def predict_labels(dists, y_train, k=1):
"""
根据距离预测测试样本的标签。
输入:
- dists: 形状 (num_train, num_test),距离矩阵
- y_train: 形状 (num_train,),训练标签
- k: 最近邻数量
返回:
- y_pred: 形状 (num_test,),预测标签
"""
num_test = dists.shape[1]
y_pred = torch.zeros(num_test, dtype=torch.int64)
_, idx = torch.topk(dists, k, dim=0, largest=False)
for j in range(num_test):
k_nearest = y_train[idx[:, j]]
y_pred[j], _ = torch.mode(k_nearest)
return y_pred
- 功能:对每个测试样本,找到 k 个最近的训练样本,基于多数投票预测标签。
- 实现细节:
torch.topk(dists, k, largest=False)
返回 k 个最小距离的索引。y_train[idx[:, j]]
获取 k 个最近邻的标签。torch.mode
计算众数作为预测标签。
- 注意:此实现仍有一个循环,性能可进一步通过向量化优化(例如,使用
torch.scatter
)。
2.4 交叉验证
def knn_cross_validate(x_train, y_train, num_folds=5, k_choices=None):
"""
执行交叉验证,评估不同 k 值的性能。
输入:
- x_train: 训练数据
- y_train: 训练标签
- num_folds: 折数
- k_choices: k 值列表
返回:
- k_to_accuracies: 字典,k_to_accuracies[k][i] 为第 i 折的准确率
"""
if k_choices is None:
k_choices = [1, 3, 5, 8, 10, 12, 15, 20, 50, 100]
x_train_folds = torch.chunk(x_train, num_folds)
y_train_folds = torch.chunk(y_train, num_folds)
k_to_accuracies = {}
for k in k_choices:
k_to_accuracies[k] = []
for i in range(num_folds):
# 划分验证集和训练集
x_val = x_train_folds[i]
y_val = y_train_folds[i]
x_tr = torch.cat([x_train_folds[j] for j in range(num_folds) if j != i])
y_tr = torch.cat([y_train_folds[j] for j in range(num_folds) if j != i])
# 计算距离并预测
dists = compute_distances_no_loops(x_tr, x_val)
y_pred = predict_labels(dists, y_tr, k)
# 计算准确率
accuracy = (y_pred == y_val).float().mean().item()
k_to_accuracies[k].append(accuracy)
return k_to_accuracies
- 功能:将训练数据分为
num_folds
折,评估不同 k 值的性能。 - 实现细节:
torch.chunk
将数据分为num_folds
份。- 对每个 k 值和每折,训练 kNN 模型并计算验证集准确率。
- 结果存储在
k_to_accuracies
字典中。
2.5 选择最佳 k
def knn_get_best_k(k_to_accuracies):
"""
选择具有最高平均准确率的 k 值(若有多个,选择最小的 k)。
"""
mean_accuracies = {k: sum(accs) / len(accs) for k, accs in k_to_accuracies.items()}
max_accuracy = max(mean_accuracies.values())
best_k = min([k for k, acc in mean_accuracies.items() if acc == max_accuracy])
return best_k
- 功能:计算每个 k 的平均准确率,选择最高者(若并列,选择最小 k)。
- 实现细节:
- 使用字典推导计算平均准确率。
- 使用列表推导找到最佳 k。
3. 关键点与优化
3.1 向量化
- 为何重要:Python 循环效率低,PyTorch 的矩阵运算可利用多线程或 GPU 加速。
- 实现:
compute_distances_no_loops
使用矩阵运算,避免显式循环,是作业的核心优化部分。
3.2 数据处理
- CIFAR-10 数据:图像形状为
(3, 32, 32)
,需展平为向量(3072,)
。 - 预处理:作业通常提供预加载的 CIFAR-10 数据,学生只需关注算法实现。
3.3 交叉验证
- 目的:通过交叉验证选择最佳 k,避免过拟合或欠拟合。
- 典型 k 值:
[1, 3, 5, 8, 10, 12, 15, 20, 50, 100]
,涵盖小到大的范围。
3.4 性能瓶颈
- 距离计算:向量化后仍需 (O(\text{num_train} \cdot \text{num_test})) 次操作,适合小数据集(如 CIFAR-10 子集)。
- 预测标签:
predict_labels
中的循环可进一步优化,使用torch.scatter
或torch.histc
。
4. 使用与测试
- 环境:Google Colab,配合
knn.ipynb
笔记本完成。 - 测试流程:
- 加载 CIFAR-10 数据(例如,500 个训练样本,250 个测试样本)。
- 运行
knn_cross_validate
评估不同 k。 - 使用
knn_get_best_k
选择最佳 k。 - 在测试集上评估最终模型。
- 提交:将
knn.py
和knn.ipynb
提交至 Autograder,验证实现正确性。
你提到的两行代码都用于从一组候选类别中选出最常见的类别(即分类任务中进行“投票”),但它们的工作方式有一些关键区别。我们来逐行分析并对比:
1. y_pred[j], _ = torch.mode(k_nearest)
含义:
-
torch.mode()
返回输入张量中出现次数最多的元素(众数)及其出现次数。 -
k_nearest
是一个向量,表示第j
个测试样本的 K 个最近邻的标签。 -
这行代码表示:将
k_nearest
中出现次数最多的标签作为预测结果。
特点:
-
简洁,直接找众数。
-
如果有多个值并列为众数(出现次数一样多),
torch.mode()
返回第一个出现的那个。 -
适用于普通的多数投票,不考虑每个标签的“权重”或顺序。
2. _, idx = torch.max(y_train[indices[:,i]].bincount(), dim = 0)
含义:
-
indices[:, i]
是第i
个测试样本的 K 个最近邻的索引。 -
y_train[indices[:, i]]
取出这些邻居的标签。 -
.bincount()
统计每个类别的出现次数,返回一个频数向量。 -
torch.max()
取出频数最大的类别索引(即预测类别)。
特点:
-
更显式地对每个类别计数,返回出现最多的标签的 索引。
-
更适合分类标签是从
0
开始的整数(例如[0, 1, 2]
)。 -
如果
y_train
的标签不是从 0 开始的整数,bincount()
可能会出错或返回稀疏结果。
总结对比:
特性 | torch.mode() | bincount + torch.max() |
---|---|---|
适用标签类型 | 任意类型,只要能比较 | 整数,且最好从 0 开始 |
冲突处理(众数不唯一) | 返回第一个出现的众数 | 返回频数最高的类别索引(也可能有冲突) |
是否更灵活 | 是 | 否(受限于 bincount 的输入格式) |
是否更高效 | 视情况而定,对小 K 取值差别不大 | bincount 可能在大量标签时更高效 |
如果你的标签是整数且从 0 开始,使用 bincount()
通常会更高效;但如果你希望代码更通用或不清楚标签结构,torch.mode()
更保险。
你目前使用的是哪种标签格式?(比如:标签是否是整数、是否从0开始)
SVM Loss
好的,我们来详细讲解 Vectorized(向量化)版本 的结构化 SVM 损失函数和梯度计算。
🚀 为什么要 vectorized?
你之前写的是 naive(朴素)版,用了两层 for 循环去遍历样本和类别,在样本多时运行会非常慢。
而 vectorized 是指利用 PyTorch/Numpy 的矩阵运算一次性完成所有样本的计算,速度更快、更简洁、更易优化。
🧠 基本变量定义(跟 naive 版一样):
W: (D, C) # 权重矩阵
X: (N, D) # 输入样本
y: (N,) # 正确分类标签,整数值 0 到 C-1
🧱 步骤拆解:
✅ 第一步:计算所有样本的得分
scores = X @ W # shape: (N, C)
✅ 第二步:取出每个样本的正确类的得分
correct_class_scores = scores[torch.arange(N), y].view(-1, 1) # shape: (N, 1)
✅ 第三步:计算 margin(结构化 SVM 损失)
margins = scores - correct_class_scores + 1
margins[torch.arange(N), y] = 0 # 正确类不参与损失
margins = torch.clamp(margins, min=0)
✅ 第四步:计算 loss(加平均和正则项)
loss = margins.sum() / N
loss += reg * torch.sum(W * W)
✅ 第五步:计算梯度 dW
关键:构造一个 mask,标记哪些 margin > 0,然后用于反向传播。
mask = (margins > 0).float() # shape: (N, C)
row_sum = mask.sum(dim=1) # 每行有多少个 margin > 0
mask[torch.arange(N), y] = -row_sum # 正确类是负的累计值
然后直接矩阵乘法:
dW = X.t() @ mask # shape: (D, C)
dW /= N
dW += 2 * reg * W
✅ 完整 vectorized 版本代码如下:
def svm_loss_vectorized(W, X, y, reg):
N = X.shape[0]
scores = X @ W # shape: (N, C)
correct_scores = scores[torch.arange(N), y].view(-1, 1)
margins = torch.clamp(scores - correct_scores + 1, min=0)
margins[torch.arange(N), y] = 0
loss = margins.sum() / N
loss += reg * torch.sum(W * W)
mask = (margins > 0).float()
row_sum = mask.sum(dim=1)
mask[torch.arange(N), y] = -row_sum
dW = X.t() @ mask
dW /= N
dW += 2 * reg * W
return loss, dW
🎯 对比 naive 和 vectorized 的区别
项目 | naive 实现 | vectorized 实现 |
---|---|---|
使用方式 | for 循环 | 全部矩阵操作 |
效率 | 慢 | 快 |
可读性 | 中等 | 高(习惯后) |
适合大数据 | 否 | 是 |
好,我们来详细讲解这一句代码:
correct_class_scores = scores[torch.arange(N), y].view(-1, 1)
🔍 目标:提取每个样本对应正确类别的得分
你已经有一个矩阵:
scores = X @ W # shape: (N, C)
-
scores[i, j] 是第 i 个样本对类别 j 的打分
-
y[i] 是第 i 个样本的正确类别的下标(整数)
我们要提取:
第 i 行中,第 y[i] 列的分数(即每个样本对自己正确类的得分)
✅ 分步解释
torch.arange(N)
产生 0 到 N-1 的整数列表,用于表示所有样本的索引。
例如 N=4:
torch.arange(4) ➝ tensor([0, 1, 2, 3])
scores[torch.arange(N), y]
这是 PyTorch 的高级索引方式:
它表示从第 i 行中选取第 y[i] 列的元素。
举个具体例子:
scores = torch.tensor([
[3.2, 1.0, -1.2],
[0.5, 2.3, 0.8],
[1.0, 0.0, 2.1],
[3.5, -0.2, 0.4]
]) # shape: (4, 3)
y = torch.tensor([0, 1, 2, 0])
那么:
scores[torch.arange(4), y]
→ tensor([
scores[0, 0], # 3.2
scores[1, 1], # 2.3
scores[2, 2], # 2.1
scores[3, 0] # 3.5
]) = tensor([3.2, 2.3, 2.1, 3.5])
.view(-1, 1)
这是 reshape 的意思,把 shape 从 (N,) 改成 (N, 1),方便广播:
tensor([3.2, 2.3, 2.1, 3.5]).view(-1, 1)
→ tensor([
[3.2],
[2.3],
[2.1],
[3.5]
])
✅ 为什么需要 **(N, 1)
为了计算 margin:
margins = scores - correct_class_scores + 1
-
scores 是 (N, C)
-
correct_class_scores 是 (N, 1)
-
广播后才能按行减去对应的正确分类得分
correct_class_scores = scores[torch.arange(N), y].view(-1, 1)
表示:提取每个样本对应的正确类别得分,并 reshape 成列向量(N, 1)方便后续广播操作。
这行代码:
margins = torch.clamp(margins, min=0)
是用来实现 结构化 SVM 损失函数中的 hinge 损失(铰链损失) 的一个关键步骤。
🧠 背景知识:Hinge 损失
在结构化 SVM 中,我们计算的每个 margin 是:
其中:
-
是样本 i 在类别 j 上的得分
-
是该样本在“正确类别”上的得分
-
只有当 时才产生损失
因此,我们使用:
✅
torch.clamp(margins, min=0)
的作用
它的功能就是将所有小于 0 的 margin 置为 0,即实现上述的:
🔧 示例演示
假设 margins 是下面这个张量:
tensor([
[-1.2, 0.5, 2.0],
[0.0, -0.7, 1.2]
])
使用 torch.clamp(margins, min=0) 后变成:
tensor([
[0.0, 0.5, 2.0],
[0.0, 0.0, 1.2]
])
🧩 总结一句话:
torch.clamp(margins, min=0)
等价于执行每个元素的:
if x < 0: x = 0
这就是 hinge 损失中保留正 margin、舍弃负值的实现方式。
需要我进一步解释 clamp 支持哪些其他参数,或者用它实现更复杂的裁剪操作吗?