最近在研究 Yolov2 论文的时候,发现作者在做先验框聚类使用的指标并非欧式距离,而是IoU。在找了很多资料之后,基本确定 Python 没有自定义指标聚类的函数,所以打算自己做一个
设训练集的 shape 是 [n_sample, n_feature],基本思路是:
- 簇中心初始化:第 1 个簇中心取样本的特征均值,shape = [n_feature, ];从第 2 个簇中心开始,用距离函数 (自定义) 计算每个样本到最近中心点的距离,归一化后作为选取下一个簇中心的概率 —— 迭代到选取到足够的簇中心为止
- 簇中心调整:训练多轮,每一轮以样本点到最近中心点的距离之和作为 loss,梯度下降法 + Adam 优化器逼近最优解,在 loss 浮动值小于 0 的次数达到一定值时停止训练
因为设计之初就打算使用自定义距离函数,所以求导是很大的难题。笔者不才,最终决定借助 Torch 自动求导的天然优势
欧氏距离
即 n 维空间中,两点的直线距离
def Eu_dist(data, center):
''' 以 欧氏距离 为聚类准则的距离计算函数
data: 形如 [n_sample, n_feature] 的 tensor
center: 形如 [n_cluster, n_feature] 的 tensor'''
return ((data[:, None] - center[None]) ** 2).sum(dim=2)
聚类器对象
使用时只需关注 __init__、fit、classify 函数
距离优化模式有三种:
- max:以簇的覆盖半径为优化目标,该模式会考虑到所有的样本点,适用于样本较为集中、样本分布密度差异巨大的情况
- mean:以簇中心到其样本的平均距离为优化目标,适用于离群点较多的情况
- sum:以样本到所有簇中心的最小距离之和为优化目标,适用情况与mean差不多
使用这个聚类器需导入 optimize 模块,其代码见另一篇文章:
Torch 梯度下降法 minimizehttps://hebitzj.blog.csdn.net/article/details/124533862
from typing import Sequence
from optimize import *
class Dist_Cluster:
''' 基于距离的聚类器
n_cluster: 簇中心数
dist_fun: 距离计算函数
kwargs:
data: 形如 [n_sample, n_feather] 的 tensor
center: 形如 [n_cluster, n_feature] 的 tensor
return: 形如 [n_sample, n_cluster] 的 tensor
mode: 距离优化模式 ('max', 'mean', 'sum')
init: 初始簇中心
patience: 允许 loss 无进展的次数
lr: 中心点坐标学习率
cluster_centers: 聚类中心
labels: 聚类结果'''
def __init__(self, n_cluster: int,
dist_fun: Callable[[torch.tensor, torch.tensor],
torch.tensor] = Eu_dist,
mode: str = 'max',
init: Optional[Sequence[Sequence]] = None,
patience: int = 50,
lr: float = 0.08):
self._n_cluster = n_cluster
self._dist_fun = dist_fun
self._patience = patience
self._lr = lr
self._mode = mode
# 初始化参数
self.cluster_centers = None if init is None else torch.tensor(init).float()
self.labels = None
self._bar_len = 20
def fit(self, data: torch.tensor, prefix='Cluster'):
''' data: 形如 [n_sample, n_feature] 的 tensor
return: 簇惯性'''
LOGGER.info(('%10s' * 3) % ('', 'cur_loss', 'min_loss'))
self._init_cluster(data, self._patience // 5, prefix)
inertia = self._train(data, self._lr, self._patience, prefix=prefix)
# 开始若干轮次的训练,得到簇惯性
self.classify(data)
return inertia
def classify(self, data: torch.tensor):
''' data: 形如 [n_sample, n_feature] 的 tensor
return: 分类标签'''
dist = self._dist_fun(data, self.cluster_centers)
# 将标签加载到实例属性
self.labels = dist.argmin(axis=1)
return self.labels
def _init_cluster(self, data, patience, prefix):
# 没有中心点时,初始化一个中心点
if self.cluster_centers is None:
self.cluster_centers = data.mean(dim=0).reshape(1, -1)
# 补全中心点
for cur_center_num in range(self.cluster_centers.shape[0], self._n_cluster):
dist = np.array(self._dist_fun(data, self.cluster_centers).min(dim=1)[0].cpu())
dist -= dist.min()
new_cluster = data[np.random.choice(range(data.shape[0]), p=dist / dist.sum())].reshape(1, -1)
# 取新的中心点
self.cluster_centers = torch.cat([self.cluster_centers, new_cluster], dim=0).float()
self._train(data, self._lr * 2.5, patience, prefix=f'Init_{cur_center_num}'.ljust(len(prefix)), init=True)
# 初始化簇中心时使用较大的lr
def _train(self, data, lr, patience, prefix, init=False):
loss_fun = lambda center: self._loss(data, center)
self.cluster_centers, interia, _ = minimize(self.cluster_centers, loss_fun, lr=lr, patience=patience,
max_iter=None, prefix=prefix, title=False, leave=not init)
return interia
def _loss(self, data, center):
sample_dist = self._dist_fun(data, center)
min_dist, self.labels = sample_dist.min(dim=1)
# 按照距离进行分类
clf_result = []
for idx in range(len(center)):
own = self.labels == idx
if torch.any(own):
clf_result.append(min_dist[self.labels == idx])
else:
clf_result.append(sample_dist[:, idx].min())
# 计算 loss 值
if self._mode == 'max':
loss = sum([dists.max() + .05 * dists.mean() for dists in clf_result])
elif self._mode == 'mean':
loss = sum([dists.mean() for dists in clf_result])
elif self._mode == 'sum':
loss = sum([dists.sum() for dists in clf_result])
else:
raise KeyError('mode 参数出错')
return loss
与KMeans++比较
if __name__ == '__main__':
import matplotlib.pyplot as plt
from sklearn import datasets
from sklearn.cluster import KMeans
import numpy as np
import warnings
warnings.filterwarnings('ignore')
class Timer:
def __new__(cls, fun, *args, **kwargs):
import time
start = time.time()
fun(*args, **kwargs)
cost = time.time() - start
return cost * 1e3 # ms
def draw_result(train_x, labels, cents, idx, title):
''' 聚类结果可视化'''
# 创建子图, 并设置标题
fig = plt.subplot(1, 2, idx, projection='3d')
plt.title(title)
# 簇中心数量, 以及每个簇的颜色
n_clusters = np.unique(labels).shape[0]
color = ["red", "orange", "yellow"]
# 分别绘制每个类别的样本
for i in range(n_clusters):
samples = train_x[labels == i]
fig.scatter(*samples[:, :3].T, c=color[i])
fig.scatter(*cents.T, c="deepskyblue", marker="*", s=100)
# 视角变换
fig.view_init(60, 110)
# 读取数据集
iris = datasets.load_iris()
iris_x = torch.tensor(iris.data)
iris_y = torch.tensor(iris.target, dtype=torch.int)
# 使用 KMeans 聚类
clf = KMeans(n_clusters=3)
print(f'Kmeans: {Timer(clf.fit, iris_x):.0f} ms')
draw_result(iris_x, clf.labels_, clf.cluster_centers_, 1, "KMeans++")
# 使用基于距离的自定义聚类
clf = Dist_Cluster(n_cluster=3, dist_fun=Eu_dist, lr=1., mode='mean')
print(f'My Cluster: {Timer(clf.fit, iris_x):.0f} ms')
draw_result(iris_x, clf.labels, clf.cluster_centers, 2, "My Cluster")
plt.show()
KMeans++ 是以欧式距离为聚类准则的经典聚类算法。在 iris 数据集上,KMeans++ 远远快于我的聚类器。但在我反复对比测试的几轮里,我的聚类器精度也是不差的 —— 可以看到下图里的聚类结果完全一致
KMeans++ | My Cluster | |
Cost | 145 ms | 1597 ms |
Center | [[5.9016, 2.7484, 4.3935, 1.4339], [5.0060, 3.4280, 1.4620, 0.2460], | [[5.9016, 2.7485, 4.3934, 1.4338], |
虽然速度方面与老牌算法对比的确不行,但是我的这个聚类器最大的亮点还是自定义距离函数
余弦相似度
根据余弦公式,给出如下计算函数
余弦距离和其它距离不同,其值越大,则距离反而越近。但是我的聚类器的机制是缩小距离和,所以需要给这个余弦距离取负
def Cos_similarity(data, refer):
''' 余弦相似度计算
data: 形如 [n_sample, n_feature] 的 tensor
refer: 形如 [n_cluster, n_feature] 的 tensor'''
data_len = (data ** 2).sum(dim=1) ** 0.5
refer_len = (refer ** 2).sum(dim=1) ** 0.5
# 计算向量模
vec_dot = (data[:, None] * refer[None]).sum(dim=-1)
# 计算余弦相似度
return - vec_dot / (data_len[:, None] * refer_len[None])
DIoU 距离
本来想用 Yolov4 检测框聚类引入的 CIoU 做聚类,但是没法解决梯度弥散的问题,所以退其次用了 DIoU
def DIoU_dist(boxes, anchor):
""" 以 DIoU 为聚类准则的距离计算函数
boxes: 形如 [n_sample, 2] 的 tensor
anchor: 形如 [n_cluster, 2] 的 tensor"""
anchor = torch.abs(anchor)
# 计算欧式距离
dist = Eu_dist(boxes, anchor)
boxes = boxes[:, None]
anchor = anchor[None]
# 计算交并面积
union_and_inter = torch.prod(boxes, dim=-1) + torch.prod(anchor, dim=-1)
inter = torch.prod(torch.minimum(boxes, anchor), dim=-1)
iou = inter / (union_and_inter - inter)
# 计算对角线长度
diag = (torch.maximum(boxes, anchor) ** 2).sum(dim=-1)
return 1 - iou + dist / diag
我提取了 DroneVehicle 数据集的 650156 个预测框的尺寸做聚类,在这个过程中发现因为小尺寸的预测框过多,导致聚类中心聚集在原点附近。所以对 loss 函数做了改进:先分类,再计算每个分类下的最大距离之和
横轴表示检测框的宽度,纵轴表示检测框的高度,其数值都是相对于原图尺寸的比例。若原图尺寸为 608 * 608,则得到的 9 个先验框为:
[ 10, 4 ] | [ 5, 12 ] | [ 21, 10 ] |
[ 43, 3 ] | [ 13, 29 ] | [ 14, 64 ] |
[ 58, 20 ] | [ 135, 37 ] | [ 43, 141 ] |