面试中高频的ML手撕题

1. K-Means

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import math
import random

X = [
[1, 2], [1, 3], [2, 2],
[8, 8], [9, 8], [8, 9]
]

def kmeans(X, k, max_iters=100):
# 采样k个簇中心
centers = random.sample(X,k)

# 每一轮迭代里面
for _ in range(max_iters):
# 初始化每个簇集合
clusters = [ [] for _ in range(k) ]

# 要计算每个样本到簇中心的距离,然后分配出去
for x in X:
# 先计算距离
distances = []
for c in centers:
distance = 0

for i in range(len(x)):
distance += (x[i] - c[i])**2
distance = math.sqrt(distance)
distances.append(distance)
# 找到最近的那个簇,然后把当前x给分配到那里去
label = distances.index(min(distances))
clusters[label].append(x)

# 当所有样本都分配完了,就要更新簇中心,为下一轮分配做准备
new_centers = []

# 对每个簇开始处理,去计算平均距离
for cluster in clusters:
# 处理一下非空的簇
if len(cluster) == 0:
new_centers.append(random.choice(X))
continue

# 计算新的簇中心,要对簇里面所有样本的特征取平均值
center = []
for i in range(len(X[0])):
total = 0
for x in cluster:
total += x[i]
mean = total / len(cluster)
center.append(mean)

new_centers.append(center)

if new_centers == centers:
break
centers = new_centers

return centers, clusters

centers, clusters = kmeans(X, k=2)

print("Centers:", centers)
print("Clusters:", clusters)

1. 简单介绍一下 K-Means 算法?

K-Means 是一种无监督学习算法,主要用于聚类,不需要标签。它会预先指定 K 个簇,首先随机选择 K 个中心点,然后计算每个样本到各个中心点的距离,将样本分配给最近的中心点,再重新计算每个簇的均值作为新的中心点,不断重复这个过程,直到中心点不再发生变化或者达到最大迭代次数。

本质上就是让相似的数据聚在一起,不相似的数据分开。通过不断更新聚类中心,最小化簇内平方误差(SSE),最终实现数据的自动聚类。

2. K-Means 为什么要计算平均值?

K-Means 的损失函数是簇内平方误差 SSE,表示所有样本点到各自所属簇中心的距离平方和。而使用均值作为聚类中心,可以使簇内所有样本到中心点的平方距离之和(也就是SSE)尽可能最小,所以每次更新中心点时都要计算平均值。

3. K-Means 有什么优点和缺点?

优点:

  • 算法简单,容易理解和实现。计算效率比较高。
  • 聚类结果比较容易解释。

缺点:

  • 需要提前指定 K 值。
  • 对初始中心点比较敏感。
  • 对异常值比较敏感。
  • 不适合形状复杂、密度差异较大的数据集。

6. K-Means 的收敛条件是什么?

通常有三种情况:第一,聚类中心不再发生变化;第二,聚类中心的变化小于设定的误差阈值;第三,达到最大迭代次数。满足其中一个条件就可以停止迭代。

7. K-Means 对数据有什么要求?需要注意什么?

K-Means 要求输入的是数值型数据,并且需要提前指定 K 值。另外,由于它基于距离计算,所以不同特征之间的量纲可能会影响聚类结果,通常需要先进行标准化处理,例如 StandardScaler。

2. 梯度下降

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# 1、实现一个机器学习场景中的梯度下降,比如用梯度下降训练线性回归。
# 训练数据
X = [1, 2, 3, 4, 5]
Y = [2, 4, 6, 8, 10]

w = 0
b = 0

epochs = 1000
lr = 0.01

n = len(X)

for _ in range(epochs):
dw = 0
db = 0

# 计算所有样本的梯度
for i in range(n):
# 这个就是公式的写法
y_pred = w * X[i] + b
error = y_pred - Y[i]

# loss是均方差,loss是所有样本的error的误差平方和,再除以均值
# (y'-y)^2 求导
# dw: 2(y'-y)(X[i])
# db: 2(y'-y)
dw += 2*(error)*X[i]
db += 2*(error)
w = w - lr * dw / n
b = b - lr * db / n
print("w:", w)
print("b:", b)

# 2、给你一个数学函数,让你实现梯度下降,求函数最小值。
# f(x)=x^2+4x+4
# 目标:求这个函数的最小值。这时候,我们不需要额外定义损失函数,因为我们要最小化的这个函数本身,就是优化目标(Objective Function)。

def gradient(lr = 0.01,epochs = 1000):
# 随机初始化一个x的值
x = 10

for _ in range(epochs):
# 计算梯度值,其实就是求导
grad = 2 * x + 4

x = x - lr * grad

# 3. 方程计算最小值
y = x ** 2 + 4 * x + 4

return x, y

x,y = gradient()
print('x:{}, y:{}'.format(x, y))

# 0=x^2+4x+4
def gradient_loss(lr=0.01,epochs = 100):
# 随机初始化一个x的值
x = 1

for _ in range(epochs):
# 计算损失函数
error = x ** 2 + 4 * x + 4
loss = error ** 2
# 对损失函数求导
grad = 2 * error * (2*x + 4)
# 更新参数
x = x - lr*grad

# 判断是否收敛。
if abs(x**2 + 4 * x + 4) < 1e-6:
break
print("迭代后的近似值:", x)

gradient_loss()

最后一个例子里的方程有重根。对 g(x)² 做梯度下降时,越靠近根梯度越小,因此少量迭代只能得到近似值。

损失函数:我们希望预测值尽可能接近真实值,所以一般会使用均方误差 MSE。求导是要对着这个损失函数去求导的。

1. 什么是梯度下降?

梯度下降是一种优化算法,主要用于寻找函数的最小值。它通过计算函数的梯度,并沿着梯度反方向不断更新参数,使函数值逐渐减小,最终达到收敛。

这里的函数其实就是损失函数。

3. 为什么要沿着梯度反方向更新?

因为梯度代表函数值上升最快的方向,而我们希望函数值不断减小,所以需要沿着梯度的反方向更新参数。

4. 学习率是什么?过大或过小有什么影响?

学习率决定每次参数更新的步长。学习率过大,可能导致错过最优点,甚至无法收敛;学习率过小,则会导致收敛速度非常慢。

5. 如果让你用梯度下降求一个方程的解,应该怎么做?

首先将方程左边的表达式记为 g(x),然后构造损失函数 L(x) = g(x)²。接着对损失函数求导,使用梯度下降不断更新 x,当损失足够小时,就认为找到了方程的解。

6. 梯度下降有哪些停止条件?

常见的停止条件有三种:

  1. 损失函数的变化小于设定阈值。
  2. 梯度接近于 0。
  3. 达到最大迭代次数。

7. 梯度下降有什么缺点?

  1. 学习率不好选择。
  2. 可能陷入局部最优。
  3. 可能出现梯度消失或梯度爆炸。
  4. 对复杂函数,收敛速度可能比较慢。

3. MHA

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import math
import torch
from torch import nn

class MHA(nn.Module):
def __init__(self, d_model, n_head):
super().__init__()

if d_model % n_head != 0:
raise ValueError('d_model 必须能被 n_head 整除')

self.d_model = d_model
self.n_head = n_head
self.dk = d_model // n_head

# Q、K、V 线性变换
self.wq = nn.Linear(d_model, d_model)
self.wk = nn.Linear(d_model, d_model)
self.wv = nn.Linear(d_model, d_model)

# 输出线性变换
self.wo = nn.Linear(d_model, d_model)

def forward(self, x):
B,L,D = x.shape

# 1. 计算 Q、K、V
q = self.wq(x)
k = self.wk(x)
v = self.wv(x)

# 拆分多头
# 形状现在变成 B,self.n_head,L,self.dk
q = q.reshape(B,L,self.n_head,self.dk).transpose(1,2)
k = k.reshape(B,L,self.n_head,self.dk).transpose(1,2)
v = v.reshape(B,L,self.n_head,self.dk).transpose(1,2)

attn = q @ k.transpose(-1,-2) / math.sqrt(self.dk)
score = torch.softmax(attn,dim=-1)
out = score @ v

out = out.transpose(1,2).reshape(B,L,D)

return self.wo(out)

class SelfAttention(nn.Module):

def __init__(self, d_model):
super().__init__()

self.d_model = d_model
self.dk = d_model

# Q、K、V 线性变换
self.wq = nn.Linear(d_model, d_model)
self.wk = nn.Linear(d_model, d_model)
self.wv = nn.Linear(d_model, d_model)

self.wo = nn.Linear(d_model, d_model)

def forward(self,x):
B,L,D = x.shape

q = self.wq(x)
k = self.wk(x)
v = self.wv(x)

attn = q @ k.transpose(-1,-2) / math.sqrt(self.dk)
score = torch.softmax(attn,dim=-1)

out = score @ v
return self.wo(out)

1. 为什么要拆分多头?

让模型能够从不同表示子空间、不同角度学习注意力关系,token之间的关联。

2. 什么是 Attention?

Self-Attention(自注意力机制)是指序列中的每个 Token 都可以与其他 Token 计算注意力分数,根据注意力权重对其他 Token 的信息进行加权求和,从而得到当前token融合了上下文信息的表示。

3. Q、K、V 分别代表什么?

  • Q(Query):查询,表示当前 Token 想获取什么信息。
  • K(Key):键,表示当前 Token 可以提供什么信息。
  • V(Value):值,表示当前 Token 实际提供的内容。

简单理解:Q 和 K 计算相关性,V 提供实际信息。

4. 为什么要除以 √dₖ?

因为 Q 和 K 的维度较大时,点积结果可能比较大,导致 Softmax 进入梯度很小的区域,影响模型训练。除以 √dₖ 可以稳定数值。

4. softmax

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import math

x = [2, 3, 5]

def softmax(x):
# 1. 找到最大值
max_x = max(x)

# 2. 计算指数(减去最大值,防止溢出)
exp_x = []
for i in x:
exp_x.append(math.exp(i-max_x))

# 3. 计算指数之和
total_x = sum(exp_x)

# 计算归一化后的logits
results = []
for i in exp_x:
results.append(i/total_x)

return results

print(softmax(x))

Q1:为什么 Softmax 要减去最大值?

防止输入数值过大时,指数运算发生溢出,提高计算的稳定性。

Q2:Softmax 有什么特点?

  1. 输出值都在 0 到 1 之间。
  2. 所有输出值之和为 1。
  3. 输入值越大,对应的概率通常越大。
  4. 常用于多分类任务的输出层。

5. RMSnorm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import math

def rms_norm(x, gamma, eps=1e-6):

# 1、计算平方和
square_sum = 0

for i in x:
square_sum += i ** 2

# 计算均方根
rms = math.sqrt( square_sum / len(x) + eps )

# 归一化
x_norm = []

for i in x:
x_norm.append(i / rms * gamma)

return x_norm

核心思想:计算均方根,然后用它对输入进行归一化。LayerNorm 是先减均值,再除以标准差;RMSNorm 不减均值,直接除以均方根。

Q1:为什么大模型使用 RMSNorm?

RMSNorm 省去了均值和方差的计算,计算过程更简单,计算开销更小,同时在很多大模型中能够取得很好的效果。

Q2:RMSNorm 中为什么要加 epsilon?

防止均方根为 0,避免除零错误,提高数值稳定性。

Q3:RMSNorm 中的 gamma 是什么?

Gamma 是可学习的缩放参数,用于调整归一化之后的特征。

6. AUC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def auc(y_true, y_score):
pos = []
neg = []

# 1、找到正负样本
for i in range(len(y_true)):
if y_true[i] == 1:
pos.append(y_score[i])
else:
neg.append(y_score[i])

# 2. 统计正样本分数高于负样本的次数
count = 0

for p in pos:
for n in neg:
if p>n:
count += 1
elif p == n:
count += 0.5

if not pos or not neg:
raise ValueError('AUC 需要同时包含正样本和负样本')
return count / (len(pos) * len(neg))

Q1:AUC 是什么?

衡量二分类模型性能的指标。随机选择一个正样本和一个负样本,模型给正样本的预测分数高于负样本的概率。AUC 越大,说明模型区分正负样本的能力越强。

Q2:AUC 的取值范围是多少?

通常在 0 到 1 之间,0.5 表示随机排序水平,1 表示完美区分。

Q3:AUC 和 Accuracy 有什么区别?

Accuracy 衡量的是模型预测正确的比例,需要指定分类阈值;AUC 衡量模型对正负样本的整体排序能力,不依赖某一个固定阈值。

7. Dropout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import random

def dropout(x, p=0.5, training=True):
if not training:
return x

result = []

for i in x:

# 随机丢弃
if random.random() < p:
result.append(0)
else:
result.append(i / (1-p))

return result

Dropout 是一种正则化方法,主要用于防止神经网络过拟合。它的做法很简单:训练过程中,随机将一部分神经元的输出置为 0,让模型不要过度依赖某几个神经元。

Q1:Dropout 有什么作用?

Dropout 通过随机丢弃部分神经元,减少神经元之间的过度依赖,从而缓解过拟合,提高模型的泛化能力。

Q2:Dropout 在训练和推理阶段有什么区别?

训练阶段随机丢弃神经元;推理阶段不再丢弃神经元,直接使用完整网络。

Q3:Dropout 为什么要缩放?保留的元素还要除以 1-p?

因为我们随机丢弃了一部分神经元,为了保持训练时输出的期望值与推理时一致,需要对保留的元素进行缩放。

8. Adam

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import math

# 目标函数:Loss(w) = x^2,这里是求一个最小值,所以是直接对函数求导。
# 梯度:grad = 2 * x

def adam(lr=0.01, epochs=1000):
# 初始化一个值
x = 5.0

# 初始化一阶矩和二阶矩
m = 0
v = 0

# 参数
beta1 = 0.9
beta2 = 0.99

eps = 1e-8

for t in range(1,epochs+1):
grad = 2 * x

m = beta1 * m + (1-beta1) * grad
v = beta2 * v + (1-beta2) * grad ** 2

# 偏差修正,这就是为什么epoch的遍历是要从 1开始到101的。
m_hat = m / (1-beta1**t)
v_hat = v / (1-beta2**t)

x = x - lr / (math.sqrt(v_hat)+eps) * m_hat

# 9. 判断是否收敛
if abs(grad) < 1e-6:
break

return x ** 2

例如训练一个分类模型:

  1. 输入数据,模型得到预测结果。
  2. 使用 Cross Entropy 计算 Loss。
  3. 通过反向传播用损失函数计算所有参数的梯度。
  4. Adam 根据梯度计算一阶矩和二阶矩。
  5. 更新模型参数。
  6. 不断重复,降低 Loss。

Q1:Adam 是什么?

Adam 是一种可以自适应调整学习率的优化算法,全称 Adaptive Moment Estimation。它结合了 Momentum(动量)和 RMSProp 的思想,通过记录梯度的一阶矩和二阶矩,通过偏差修正动态调整 参数的学习率。

Adam 和梯度下降本质上都是优化算法,都是用来优化某个目标函数(通常是损失函数)的。它们的区别主要在于:参数更新的方式不同。

  • 一阶矩:梯度的指数移动平均,类似动量。
  • 二阶矩:梯度平方的指数移动平均,代表梯度的大小。用于调整更新步长。

Q2:Adam 的一阶矩和二阶矩分别是什么?

一阶矩是梯度的指数移动平均,记录梯度方向;二阶矩是梯度平方的指数移动平均,记录梯度大小。

Q3:为什么 Adam 需要偏差修正?

因为一阶矩和二阶矩初始值都是 0,训练初期会存在偏差,因此需要进行修正,使估计更加准确。

Q4:Adam 和 SGD 有什么区别?

SGD 通常使用统一的学习率更新参数,而 Adam 会根据每个参数的梯度历史,自适应调整更新步长。

Q5:Adam 一定比 SGD 好吗?

不一定。Adam 通常收敛较快,适合深度学习训练;SGD 在某些任务中可能具有更好的泛化表现。