from scholarly_infrastructure.logging.nucleus import logger, print
from sklearn.datasets import load_digits, fetch_openml
更高效的支持向量机算法实现及其在手写数字识别中的应用——00绪论
大数据机器学习课程第二次实验项目
1 绪论
2 代码与文档格式说明
本文档使用Jupyter Notebook编写,所以同时包括了实验文档和实验代码。
本次实验项目采用了 Quarto + nbdev 的系统来发布Jupyter Notebook, 因而我们的实验文档导出为pdf和html格式可以进行阅读,而我们的代码也导出为python模块形式,可以作为代码库被其他项目使用。
我们这样做的好处是,避免单独管理一堆 .py 文件,防止代码冗余和同步混乱,py文件和pdf文件都是从.ipynb文件导出的,可以保证实验文档和代码的一致性。
!!! important
可以通过以下命令安装我们实验的代码:
```shell
pip install git+https://github.com/Open-Book-Studio/THU-Coursework-Machine-Learning-for-Big-Data.git
```
我们的代码导出为了python模块形式,通过以下命令导入:
```python
from thu_big_data_ml.svm import *
```
https://github.com/Open-Book-Studio/THU-Coursework-Machine-Learning-for-Big-Data.git 是我们本次大数据机器学习课程实验的代码仓库地址,
而这次作业中,我开发的另一个用于图像分类科研的开源项目有名的分类框架 (NamableClassify)也相应地进行了代码更新,把我们这次作业实现的SVM算法加入到了其中。接下来我们也会用到这个项目中的一些代码(比如分类评测指标)来完成本次作业。我还构建了我们课题组的基础依赖库ScholarlyInfrastructure,对标fastcore
库,对AI科研经常会用到的一些基础性地、和Python语言的表达力有关的代码进行了整理,比如PyTorch模型检查、清晰的日志、实验参数管理、异常处理、argmax自动函数优化等。这里我们用到了实验参数管理功能,把SVM的超参数表达为随机变量,随即使用元参数优化算法进行搜索。
pip install git+https://github.com/2catycm/NamableClassify.git
pip install git+https://github.com/THU-CVML/ScholarlyInfrastructure.git
from namable_classify import *
from scholarly_infrastructure import *
以上代码库开源在github,欢迎各位同学、老师们提出宝贵意见,或者加入我们的开发一起完善,构建更加优质的科研工具。
!!! important
本文档具有一定的交互性,建议使用浏览器打开html文件,这样比pdf文件阅读体验更佳。
由于这次项目作业内容太多,为了便于管理,我们将项目文档和代码分为了不同几个部分。
3 实验目的与项目要求
老师给我们的要求是
⼿动实现一个 SVM 分类器,熟悉 SVM 的原理与优化求解 SVM 分类器的算法的过程。
MNIST 数据集分类,报告你在测试集上的准确率,与已有SVM库进行对比。
对 SVM 分类器训练的超参数(包括收敛终止条件,学习率等)进行调优
构建使用 kernel 方法的 SVM 分类器
对比不同 SVM 方法
作为Top1大学的学生,我们不仅需要完成以上内容,还需要进行一些深入的思考和探索。
- 现有最流行的SVM分类的实现是sklearn以及其背后的libsvm,由于C++编写,确实在CPU上运行很快。但是,如果我们需要在GPU上运行,是否可以考虑用CUDA等方法来加速呢?我们决定尝试一下实现速度更快的SVM分类器。
- sklearn自带的调参GridSearchCV和RandomizedSearchCV都具有一定的局限性。参考谷歌调参手册,我们使用科学的实验设计来对SVM分类算法的元参数进行搜索,从而实现更高的分类精度,并且获得一些insight,便于我们后续科研中使用SVM。
- 实现自己的kernel方法。实现之后,不仅像2那样参考谷歌调参手册和假设检验来比较数值上的性能,还使用可视化工具来对不同核函数的效果进行比较。
- 除了李航《统计学习方法》的介绍逻辑,也补充阅读周志华的《机器学习》西瓜书,加深对SVM的理解。进一步阅读深度学习时代的SVM前沿论文,探索一些更加新的SVM策略,为后续科研提供参考。
事不宜迟,我们开始动手吧!
4 实验数据
MNIST 数据库是由 Yann et. al. 提供的⼿写数字数据库⽂件, 官网地址为 http://yann.lecun.com/exdb/mnist/。 主要包含了 60000 张的训练图像和 10000 张的测试图像
类似于上一次Project(KD树实现KNN),我们使用 sklearn CI(持续集成)测试用例的load_digits数据集,而不只是使用原始的MNIST数据集,来加快实验的效率。并且在划分数据集时,train_test_split应当使用stratify参数,以确保每一类样本的比例相同。
# dataset_dict_uci_digits = load_digits(as_frame=True)
= load_digits(as_frame=False)
dataset_dict_uci_digits = fetch_openml("mnist_784", as_frame=True)
dataset_dict_full_mnist dataset_dict_uci_digits.keys(), dataset_dict_full_mnist.keys()
( dict_keys(['data', 'target', 'frame', 'feature_names', 'target_names', 'images', 'DESCR']), dict_keys(['data', 'target', 'frame', 'categories', 'feature_names', 'target_names', 'DESCR', 'details', 'url']) )
dataset_dict_uci_digits.target_names
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
4.1 sklearn_to_X_y_categories
sklearn_to_X_y_categories (dataset_dict)
Exported source
import pandas as pd
import numpy as np
Exported source
def sklearn_to_X_y_categories(dataset_dict):
= dataset_dict['data']
X = dataset_dict['target']
y if isinstance(X, pd.DataFrame):
= X.values
X:np.array if isinstance(y, pd.Series):
= y.values
y:np.array # if y.dtype.name == 'category':
# categories = y.dtype.categories
# else:
= X.astype(np.float32)
X = y.astype(np.int64)
y = np.unique(y)
categories # print(str((X.shape, X.dtype, y.shape, y.dtype, categories)))
print(X.shape, X.dtype, y.shape, y.dtype, categories)
return X, y, categories
= sklearn_to_X_y_categories(dataset_dict_uci_digits)
X, y, categories = sklearn_to_X_y_categories(dataset_dict_full_mnist) X_full, y_full, categories_full
Sat 2024-11-16 22:11:02.268075
INFO ((1797, 64), dtype('float32'), (1797,), dtype('int64'), array([0, 1, 2, 3, 4, 5, 6, 7, 8, nucleus.py:55 9]))
Sat 2024-11-16 22:11:02.537849
INFO ((70000, 784), dtype('float32'), (70000,), dtype('int64'), array([0, 1, 2, 3, 4, 5, 6, 7, 8, nucleus.py:55 9]))
划分数据集为训练集和测试集。 注意这里与官方的mnist划分有所不同,但是是合理而且科学的,因为正确使用了stratify
参数。
4.2 make_train_val_test
make_train_val_test (X, y, val_size=0.1, test_size=0.2, random_state=42, normalize=True)
Exported source
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
Exported source
def make_train_val_test(X, y, val_size=0.1, test_size=0.2, random_state=42, normalize=True):
= train_test_split(X, y, test_size=0.2, random_state=42,
X_train, X_test, y_train, y_test =y)
stratify# print(len(X_train), len(X_test))
if normalize:
= StandardScaler()
scaler = scaler.fit_transform(X_train)
X_train = scaler.transform(X_test)
X_test # 进一步划分出验证集,用于调参、early stopping等。
= train_test_split(X_train, y_train, test_size=0.1, random_state=42,
X_train, X_val, y_train, y_val =y_train)
stratifyprint(len(X_train), len(X_val), len(X_test))
return X_train, X_val, X_test, y_train, y_val, y_test
= make_train_val_test(X, y)
X_train, X_val, X_test, y_train, y_val, y_test = make_train_val_test(X_full, y_full) X_train_full, X_val_full, X_test_full, y_train_full, y_val_full, y_test_full
Sat 2024-11-16 22:11:02.748631
INFO (1293, 144, 360) nucleus.py:55
Sat 2024-11-16 22:11:07.325810
INFO (50400, 5600, 14000) nucleus.py:55
获得 PyTorch 格式 的Dataset, 进一步得到 PyTorch Lightning 的 DataModule
4.3 get_torch_dataset
get_torch_dataset (X, y)
Exported source
import torch
import lightning as L
Exported source
def get_torch_dataset(X, y):
= torch.tensor(X, dtype=torch.float32)
X_tensor = torch.tensor(y, dtype=torch.long)
y_tensor = torch.utils.data.TensorDataset(X_tensor, y_tensor)
dataset return dataset
= get_torch_dataset(X_train, y_train)
train_set = get_torch_dataset(X_val, y_val)
val_set = get_torch_dataset(X_test, y_test)
test_set = get_torch_dataset(X_train_full, y_train_full)
train_set_full = get_torch_dataset(X_val_full, y_val_full)
val_set_full = get_torch_dataset(X_test_full, y_test_full) test_set_full
import lightning as L
= L.LightningDataModule.from_datasets(
data_module =train_set,
train_dataset=val_set,
val_dataset=test_set,
test_dataset=test_set,
predict_dataset=128,
batch_size=4
num_workers
)= L.LightningDataModule.from_datasets(
data_module_full =train_set_full,
train_dataset=val_set_full,
val_dataset=test_set_full,
test_dataset=test_set_full,
predict_dataset=128,
batch_size=4
num_workers )
4.4 process_sklearn_dataset_dict
process_sklearn_dataset_dict (dataset_dict:dict, return_type:Literal['numpy','torch','lightn ing','pandas','all'])
Exported source
from typing import Literal
Exported source
= Literal['numpy', 'torch', 'lightning', 'pandas', 'all']
ReturnType
def process_sklearn_dataset_dict(dataset_dict:dict, return_type:ReturnType):
= sklearn_to_X_y_categories(dataset_dict)
X, y, categories = make_train_val_test(X, y)
X_train, X_val, X_test, y_train, y_val, y_test = get_torch_dataset(X_train, y_train)
train_set = get_torch_dataset(X_val, y_val)
val_set = get_torch_dataset(X_test, y_test)
test_set = L.LightningDataModule.from_datasets(
data_module =train_set,
train_dataset=val_set,
val_dataset=test_set,
test_dataset=test_set,
predict_dataset=128,
batch_size=4
num_workers
)if return_type == 'numpy':
return X_train, X_val, X_test, y_train, y_val, y_test
elif return_type == 'torch':
return train_set, val_set, test_set
elif return_type == 'lightning':
return data_module
elif return_type == 'pandas':
raise NotImplementedError("Pandas not implemented yet") # 这里可以用 dataset_dict 的 frame, 但是 train test split 还有预处理。
elif return_type == 'all':
return X_train, X_val, X_test, y_train, y_val, y_test, train_set, val_set, test_set, data_module, categories
else:
raise ValueError(f"Invalid return_type: {return_type}")
5 理论回顾
5.1 SVM有哪些优化形式?我们选择哪种来实现代码?
参考论文1和论文2,以及sklearn代码库中的分法,支持向量机(SVM)的优化方法最主要地分为两大流派:
- 使用hinge loss(或者周志华西瓜书SVM章节提到的其他surrogate loss)对线性支持向量机软间隔原始问题进行随机梯度下降SGD(或者西瓜书提到坐标下降也可以)。
- 使用SMO算法(或者通用的凸二次规划算法)来求解Kernel SVM的对偶问题(也可以求解Linear Kernel)。
这是两个不同形式的问题:
- 参数保存上,(我个人认为)后者是非参数化方法,需要保存训练集中的支持向量,可能会有很多个,而前者训练成功后就是w和b,可以认为是参数化方法。
- 前者与现代的深度学习方法非常兼容,可以端到端地优化,作为深度学习方法的一个特殊loss。而后者虽然也有很多文章将CNN预训练的feature加上SVC去做分类器,但是本身的优化问题是不一样的优化问题。前者的优化难度和神经网络类似(不是简单,是很难),后者却对数据量非常敏感,数据量大的时候会很慢(不少论文提到对大数据集使用SVM仍然是研究热点,西瓜书指出其时间复杂度理论上不可能低于O(m^2))。
- 后者支持核方法,而前者有论文和博客指出,SGD不可能优化Kernel Method的SVM。
- 对于前者,论文3说使用GD来优化的情况下,对线性可分数据集而言,hard margin SVM和Logistic Regression是等价的。
5.2 SVM如何实现多分类?
李航书上介绍的SVM是用于二分类问题的,然而本次项目我们需要做MNIST手写数字分类,这需要多分类,因而我们必须了解SVM如何实现多分类。
周志华西瓜书告诉我们认为普通的二分类分类器,可以通过ovr或者ovo策略来实现多分类。那么SVM本身有没有更加独特的技巧去实现多分类呢?
我们参考这个课件。
目前,构造SVM多类分类器的方法主要有两类:
- 直接法(也就是我说的独特的方法):直接在目标函数上进行修改,将多个分类面的参数求解合并到一个最优化问题中,通过求解该最优化问题“一次性”实现多类分类。
- 间接法(所有二分类器都可以这么操作得到多分类器):主要是通过组合多个二分类器来实现多分类器的构造,常见的方法有one-against-one(OvO)和one-against-all(OvR)两种。具体来说,
- 一对多法(One-Versus-Rest, OvR):训练时依次把某个类别的样本归为一类,其他剩余的样本归为另一类,这样k个类别的样本就构造出了k个SVM。分类时将未知样本分类为具有最大分类函数值的那类。
- 一对一法(One-Versus-One, OvO):将n个类别两两配对,产生n(n-1)/2个二分类任务,获得n(n-1)/2个分类器,新样本交给这些分类器,得到n(n-1)/2个结果,最终结果投票产生。
5.3 SVM如何实现概率输出?
尽管李航书第一章把SVM是归类为非概率模型,实际上SVM经过一定的操作,是可以得到概率输出的。经典机器学习sklearn库的学习器有一个重要的API,predict_proba()
,而sklearn中的SVC同样支持该API的调用。
那么SVM概率输出有哪些方法呢?是否要用到点和分离超平面的几何间隔呢?经过资料查询,我发现SVM的概率输出几种方法:
- Sigmoid函数转换:可以将实轴上的数值投射到[0,1]上,即将一个输出实值抓化为一个概率值。比如一个分类器的分界线为0,大于0标为+1,小于0标为-1;如果使用sigmoid函数套一下输出值,我们就可以说,输出为0时标为+1的概率为0.5;输出为2时标为+1的概率为0.8等。
- Platt Scaling:这是libsvm中使用的一种方法,核心思想是把分类的结果作为新的训练集,用logistics回归再训练一个关系,得到具体的概率值。
- CalibratedClassifierCV:在scikit-learn中,可以使用专门的函数CalibratedClassifierCV对训练好的分类器模型进行校准,校准过程用到了cross-validation。
在使用predict_proba()
函数时,返回的是一个n行k列的数组,第i行第j列上的数值是模型预测第i个预测样本为某个标签的概率,并且每一行的概率和为1。
5.4 相关工作
类似于我们这次Project的,实现SVM的代码仓库有哪些?
教育学习目的的库:
- https://github.com/Kaslanarian/libsvm-sc-reading?tab=readme-ov-file
- 这篇文章写得非常用心,阅读libsvm源码解读地非常详细。
- https://github.com/lzx1019056432/Be-Friendly-To-New-People/tree/master/SVM%E6%94%AF%E6%8C%81%E5%90%91%E9%87%8F%E6%9C%BA
- 作者历时一个月,终于实现了SMO版本的SVM。但是我们做这个Project只有一周,我们要站在巨人的肩膀上实现地更好。
- https://github.com/Learner0x5a/SVM-SMO
- 作者推导良久,终于搞懂了SVM以及SMO的公式,实现了符合sklearn接口的SVM。
- https://github.com/shicaiwei123/svm-smo?tab=readme-ov-file
- 作者使用numpy实现了 SMO,并且使用ovo策略实现了多分类。
- https://github.com/kazuto1011/svm-pytorch
- 作者使用早期版本的PyTorch实现了SVM。
- https://bytepawn.com/svm-with-pytorch.html
- 这篇文章实现了二分类情况下,更早期PyTorch版本下实现的SVM。并且开源到github jupyter notebook。
实际部署在工业界的Python库:
- sklearn 本质上调用了 libsvm
- 注意sklearn很长一段时间不会利用GPU优化SVM! https://stackoverflow.com/questions/35292741/what-svm-python-modules-use-gpu
- https://pypi.org/project/svmlight/
非Python语言的库
- C++ libsvm https://github.com/cjlin1/libsvm
- GPU升级版,基于CUDA/C++实现 https://mklab.iti.gr/results/gpu-accelerated-libsvm/
- Rust https://athemathmo.github.io/rusty-machine/doc/rusty_machine/learning/svm/index.html
- Rust https://docs.rs/linfa-svm/latest/linfa_svm/
可以看出这次Project的难度不小,我们需要对SVM的原理有深刻的理解,而且需要有较强的工程能力,才能手动实验一个SVM。
6 实验内容
6.1 分类的评价指标
开尔文爵士曾说,如果你连measure都做不到,谈不上improve。所以我们先measure。
Exported source
from namable_classify.metrics import compute_classification_metrics
compute_classification_metrics?
Signature:
compute_classification_metrics(
y_true: numpy.ndarray,
y_pred_logits: numpy.ndarray = None,
logits_to_prob: bool = False,
y_pred: numpy.ndarray = None,
labels: list[int | str] | None = None,
supress_warnings: bool = True,
y_pred_metrics_only: bool = False,
)
Docstring: <no docstring>
File: ~/repos/novelties/cv/cls/NamableClassify/namable_classify/metrics.py
Type: function
这里涵盖了 from sklearn.metrics import roc_auc_score, top_k_accuracy_score, matthews_corrcoef, f1_score, precision_score, recall_score, log_loss, balanced_accuracy_score, cohen_kappa_score, hinge_loss, accuracy_score
当结果有意义的时候,这些指标都会被计算,用于评价模型的精度。
6.2 调库实现SVM
为了给我们后面的实验一个参照,我们调用现有代码库的SVM,关注其精度与速度的情况。 当然如果我们Project在此收尾,只能酌情被扣除分数。 在本节之后,我们将使用 PyTorch 和 numpy 这样的基础科学计算库,来在GPU和CPU上实现SVM及其优化。
!!! important
本次Project首先展示了几个常用的SVM库的精度与速度,并且对其进行调参;随后本次Project基于基础科学计算库手写实现了SVM及其优化,和前面的库的精度与速度进行了对比。
接下来的内容请见文件 01sv_use_lib
6.3 实现 Hinge Loss+SGD 版本的 Soft Margin Linear SVM
我们现在来实现与from sklearn.linear_model import SGDClassifier
等价的 SVM,但是我们基于PyTorch实现,在GPU上面运行,期望能在大型数据集上比sklearn的实现快。
这部分内容请见文件 02svm_handy_crafted_linear
6.4 对手动实现的SVM进行调参
这部分内容请见文件 02svm_handy_crafted_linear
6.5 附加题: 对比不同 kernel 方法下的 SVM 分类器 (对完整SVM进行调参)
这一题本质上是让我们以 kernel 的选择(也包括选择线性Kernel)作为目标元参数,其他参数作为冗余或固定元参数,进行调参实验,发现不同 kernel 方法下的 SVM 分类器的分类效果数值上的区别及其显著性,并且从可视化分析上也作出进一步解释。
这部分内容请见 03svm_kernel_hpo
6.6 附加题: 构建使用 kernel 方法的 SVM 分类器 (手动实现SMO)
这也就是让我们手动实现 SMO 优化算法以及Kernel Method。
这部分内容请见 04svm_handy_crafted_kernel