# ML_Course_Lab **Repository Path**: liuzhetan/ML_Course_Lab ## Basic Information - **Project Name**: ML_Course_Lab - **Description**: 机器学习系统课程项目 - **Primary Language**: Unknown - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 2 - **Forks**: 0 - **Created**: 2024-06-15 - **Last Updated**: 2025-06-02 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # MLSystem Course Lab ### 简介 这个项目选取了阿里巴巴天池大赛中的一个学习赛([零基础入门数据挖掘-心跳信号分类预测](https://tianchi.aliyun.com/competition/entrance/531883))作为课程项目,目的是学习和上手一些常用的机器学习&深度学习训练和调参框架。 >代码已上传至[gitee仓库](https://gitee.com/liuzhetan/ML_Course_Lab.git) ### 任务介绍 赛题以预测心电图心跳信号类别为任务,数据集报名后可见并可下载,该数据来自某平台心电图数据记录, 总数据量超过20万,主要为1列心跳信号序列数据,其中每个样本的信号序列采样频次一致,长度相等。 为了保证比赛的公平性,将会从中抽取10万条作为训练集,2万条作为测试集A,2万条作为测试集B,同时会对心跳信号类别(label)信息进行脱敏。 ### 数据集分析 [训练数据集](./data/train.csv)中包含了10万条数据,含有三个字段,其`heartbeat_signals`为心跳时序数据,`label`为对应的类别标签。 **字段表** | Field | Description | |-------------------|-----------------| | id | 为心跳信号分配的唯一标识 | | heartbeat_signals | 心跳信号序列 | | label | 心跳信号类别(0、1、2、3) | 在[Notebook data_analyse](data_analyse.ipynb)中对数据进行一些分析。 首先将读取csv文件,将原始数据转化为易于处理的`pd.DataFrame`和`pd.Series`: ```python import pandas as pd train_path = "./data/train.csv" train_data = pd.read_csv(train_path, delimiter=',', index_col='id') def __split(s:str): return list(map(float,s.split(','))) train_x = pd.DataFrame(map(__split, train_data['heartbeat_signals'])) train_y = train_data['label'] ``` 然后做出数据分布的饼状图: ![img](./imgs/sample_portion.png) 可以看到不同类别的数据在数量上占比存在较大的差异,标签为0的样本数目是标签为1的样本 数目的17.8倍。 数据量的偏差会导致模型在某些类别样本的准确率上高于其他的样本。对此, 可以使用数据增强的手段增加某些占比样本的数量,例如: 1. 给样本增加一些随机噪声 2. 对序列进行一些平移 > 这个步骤在最后提交的时候假如某些类别样本的正确率明显低于其他样本时再考虑,因为增加数据量会显著增加训练时间,目前没有来得及考虑。 每个类别挑选两个样本,作出每个类别样本的序列图以及散点分布图: ![img](./imgs/series_label.png) ![img](./imgs/series_scatter.png) ### 数据处理 原始序列的数值分布在[0,1]区间内,没有缺失值,序列长读严格为205个时间步。 数据非常规范,不需要做清理工作 对原始数据使用了以下的处理方式以提取特征: 1. 滑动窗口平均 2. 差分 3. 剪除尾部的0以缩小序列长度(仅在KNN算法中使用) 接下来说明以上各个处理方式的作用 #### 滑动窗口平均 pandas库提供了一系列[窗口操作](https://pandas.pydata.org/docs/user_guide/window.html), 其中滑动窗口平均在某个位置上的值是窗口内所有值的平均,可以有效滤除噪声,以下是使用不同大小的窗口 进行滑动平均得到的效果: ![img](./imgs/move_averaging.png) 可以看到通过设置不同大小的窗口可以抹平原始序列中不平整的起伏波动(噪声)。 在pandas中提供了窗口操作`rolling`,以下代码实现对 序列`x`的滑动平均操作: ```python import pandas as pd # x: pd.Series x.rolling(window=window_size).mean().dropna() ``` #### 差分 差分,将序列前后的两个元素元素相减,得到序列的变化量。 在pandas中调用`pandas.Series.diff(periods=k).dropna()`即可实现k 阶差分。 差分可以缓解特征在时间维度上平移造成的识别问题,这个问题 在某些分类算法中难以处理。例如:假如使用基于[DTW距离](https://pandas.pydata.org/docs/user_guide/window.html) 的KNN分类器进行分类,在使用DTW距离计算序列相似度时,存在 由于波形的位置平移导致计算得到的DTW相似度降低的情况: ![img](./imgs/dtw_distance.png) 第一张图,没有差分的情况下,存在一个label为3的序列 到某条label为0的序列的DTW距离更近,可以明显看到,是由于标签不同 的两条序列后半段波峰的在时间轴上位置更接近而不是形状更接近造成的。 ![img](./imgs/diff_2.png) 第二张图,经过差分后,标签同为0的两条序列的DTW距离相比标签不同的序列的 DTW距离要小。 以上通过两个特例阐述差分的作用,下面量化研究差分对序列的潜在影响,并选取 合适的差分阶数。 将训练数据按照标签分成4个类别,然后每个类别两两计算平均DTW相似度得到一个 4X4的相似度矩阵,假设矩阵对角线上的元素为`x_i`,每个元素减去改行的`x_i`再除以`x_i` 最后对矩阵元素取平均值,得到差异分数score,代码如下: ```python def compute_avg_dist(x: pd.DataFrame, y: pd.DataFrame, process_func=None): num = min(len(x),len(y)) num = max(num, 1500) # num = min(num, 5000) sum = 0 for i in range(num): if process_func is not None: d = dtw(process_func(x.iloc[i]), process_func(y.iloc[-i])).distance else: d = dtw(x.iloc[i], y.iloc[-i]).distance # distance, _ = fastdtw(x, y) sum += d return sum / num def wrapper_func(x:pd.Series): return process_series(x, diff=2) # compute score def compute_score(dist_arr: np.ndarray): res = [] row_sz, col_sz = dist_arr.shape for i in range(row_sz): res.append((dist_arr[i] - dist[i][i]).mean() / dist[i][i]) return np.asarray(res).mean() # process data dist = np.zeros((4,4)) for i in range(4): for j in range(i,4): dist[i,j] = compute_avg_dist(train_dict[i], train_dict[j], wrapper_func) dist[j,i] = dist[i,j] print(f"finish ({i},{j})") print(dist) print(f"Score: {compute_score(dist)}") ``` 最后得到的结果如下,在选择差分为2时,score得分更高,不同类别的差异度更明显, 并且观察相似度矩阵可以看到没有差分时,存在某一行或者某一列的元素比对角线元素的 DTW距离更近的,而在差分为2时消除了这种现象。 > 测试过不同的阶数,在选择2时相对较好 | diff | score | |------|------| | 0 | 0.102 | | 2 | 0.14 | ```text No diff: [[12.85094668 16.50644255 15.54754316 14.13269437] [16.50644255 17.77298403 18.45647421 16.7922254 ] [15.54754316 18.45647421 15.95284821 15.11869848] [14.13269437 16.7922254 15.11869848 11.37278471]] Score: 0.10211654175898055 Diff = 2: [[7.28299243 7.60404436 7.66482293 8.31067169] [7.60404436 7.25777637 8.1193659 8.7233547 ] [7.66482293 8.1193659 6.20902892 7.02647185] [8.31067169 8.7233547 7.02647185 5.92307348]] Score: 0.14637029293740475 ``` #### 截尾 截除序列后部分多余的0,由于目前的实现上对于序列样本截尾后的长度不一样, 只用于在KNN中加速DTW计算。 #### 处理函数 这里通过调整`process_series`函数的参数对序列进行处理 ```python def process_series(input_x: pd.Series, cut_zero: bool=False, window_size: int=0, diff: int = 0) -> pd.Series: res = input_x if isinstance(input_x, pd.Series) else pd.Series(input_x) if cut_zero: idx = res[res > 0].index[-1] res = res.loc[:min(idx+2*window_size,len(res))] if window_size > 0: res = res.rolling(window=window_size).mean().dropna() if diff > 0: res = res.diff(periods=diff).dropna() return res ``` ### 模型选择 时间序列分类问题在学术界有相当多的研究,本项目中选取了几个比较常用了模型: 1. K近邻算法结合DTW距离 2. 支持向量机(SVM) 3. 深度学习方法:[InceptionTimePlus](https://arxiv.org/pdf/1909.04939)、Transformer #### K近邻算法结合DTW距离 使用K近邻算法结合DTW距离作为距离度量。 K近邻算法的又是在于不需要训练,但是在分类的过程中需要计算目标到所有样本的距离, 并选择最近的k个样本投票。当数据量增加的时候推理的代价非常大,虽然`sklearn`库中 提供了数据结构,例如[Ball Tree](https://scikit-learn.org/stable/modules/neighbors.html#ball-tree)用于加速搜索。但是在数据量特别大时,索引的构建 也需要耗费相当多的时间。 我们在小规模采样的数据集(每个类别采样200条)上做了[实验](./knn.ipynb): ```python def dist_wrapper(x:pd.Series, y:pd.Series): return dtw_distance(x,y) # select first 200 samples import time from joblib import parallel_backend start = time.time() knf = KNeighborsClassifier(n_neighbors=5, algorithm='ball_tree', leaf_size=205 * 4, metric=dist_wrapper) with parallel_backend('threading', n_jobs=8): knf.fit(X_train[:200], y_train[:200]) end = time.time() print(f"fit takes {end - start}s") start = time.time() with parallel_backend('threading', n_jobs=8): print(knf.score(X_test[:200], y_test[:200])) end = time.time() print(f"vid takes {end - start}s") ``` 最终正确率为85%,训练用时`0.9`秒,但是验证200条数据用了`172.9`秒,而且推理的时间会随着训练数据规模的增大而增长。在训练数据量达到6000时,平均的推理时间达到了8秒。对于有10万数据的训练集,每条数据的推理可能要超过一分钟,如果测试集有几万条数据,那么推理的时间开销是无法忍受的。由此放弃了KNN算法。 ![img](./imgs/knn_cost.png) #### 支持向量机(SVM) 支持向量机是机器学习中的经典方法。`sklearn`的支持向量机`SVC`类主要有三个参数: 1. 核函数-`kernel` 2. 正则化系数-`C` 3. 项数(仅多项式核函数)-`degree` 使用`HalvingGridSearchCV`进行超参数搜索,它使用连续减半策略加快搜索速度。连续减半(SH)就像是候选参数组合之间的锦标赛。SH 是一个迭代选择过程,在第一次迭代中,所有候选参数(参数组合)都会用少量资源进行评估。在下一次迭代中,只有部分候选参数被选中,并分配到更多资源。对于参数调整,资源通常是训练样本的数量,但也可以是一个任意的数值参数,如随机森林中的 n_estimators。 为了加快搜索速度,先在均匀抽样的10000条数据的数据集中使用`HalvingGridSearchCV`选出最优超参数,然后使用选中的超参数在整个训练集上进行五折交叉验证,代码如下: > 相关代码,参见[Notebook svm](./svm.ipynb) ```python param_grid= {'kernel': ('linear', 'poly', 'rbf'), 'C': [1, 10, 100], 'degree': [2,3],} base_estimator = SVC(gamma='scale', random_state=42, decision_function_shape='ovo') with parallel_backend('threading', n_jobs=8): sh = HalvingGridSearchCV(base_estimator, param_grid, cv=5, factor=2, min_resources='exhaust').fit(x_sample, y_sample) print(f'Best parameter: {sh.best_params_}') print(f'Samples for each iteration: {sh.n_resources_}') ``` 最后在整个训练集上的5折交叉验证结果如下图,准确率达到98%: ![img](./imgs/svm_result.png) #### 深度学习方法 深度学习方法的类别非常多,我们首先在`Transformer`模型上做实验,训练了30个epoch,精度收敛到64%左右。看来使用使用`Transformer`模型拟合效果不是很好。 然后使用了一个专用于时序数据的深度学习算法库-[tsai](https://github.com/timeseriesAI/tsai)。它提供了学术界近几年的一些深度算法实现。我们选用了基于CNN的**InceptionTimePlus**,它的训练速度相比基于Transformer的模型要快、超参数较少,并且在经过测试,在数据集上取得了超过99%的准确率。 使用 [Ray](https://docs.ray.io/en/latest/index.html) 进行自动超参数搜索。对深度学习进行超参数搜索需要配备较大的内存(在本地16G的机器上训练由于占用内存过多直接导致Windows蓝屏),最好使用配备的GPU和大内存的服务器。在[colab](https://colab.research.google.com/)上使用一张A100训练半个小时左右。 > Colab自动超参数搜索训练代码参考`InceptionTimePlus_colab.ipynb`。 在本地选择训练超参数训练了一个**InceptionTimePlus**模型,最终在训练集和验证集的上的精度都超过了99%。 ![img](./imgs/local_train_loss.png) ![img](./imgs/local_train_acc.png) ### 部署 使用flask框架构建了一个简单的web服务器,它接收由客户端通过POST请求发来的包含若干序列的json数组,返回分类标签数组,实现代码位于目录下的脚本`flask_server.py`中 运行flask服务器的方式,在终端执行命令: ```shell flask --app flask_server run ``` 在 Jupyter Notebook `test_server.ipynb` 中使用`requests`库向**http://localhost:5000/svm** 或者 **http://localhost:5000/itp** 分别请求 SVM 和 InceptionTimePlus 模型。 使用Dockerfile构建镜像并部署,首先安装NVIDIA Container Toolkit > [参考](https://saturncloud.io/blog/how-to-install-pytorch-on-the-gpu-with-docker/) ```shell distribution=$(. /etc/os-release;echo $ID$VERSION_ID) curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add - curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list usdo apt-get update sudo apt-get install -y nvidia-container-toolkit # Now, configure the Docker daemon to recognize the NVIDIA Container Runtime: sudo nvidia-ctk runtime configure --runtime=docker # Restart the Docker daemon to complete the installation after setting the default runtime: sudo systemctl restart docker ``` 构建并运行镜像 ```shell sudo docker build -t ml_lab:1.0 . -f Dockerfile sudo docker run -it -d -p 5000:5000 --gpus all ml_lab:1.0 ``` 运行测试脚本: ```shell python test_server.py ``` ![img](./imgs/docker_inference.png) ### 总结 通过本项目,学习了: 1. 时间序列处理和分析的方法 2. 机器学习框架sklearn、深度学习框架pytorch和时间序列库tsai 3. 超参数自动搜索的相关算法实际上手体验