# 从数据到洞察:实战解析泰迪杯B题中的MET值分析与活动模式挖掘
如果你正在准备泰迪杯数据挖掘挑战赛,尤其是面对B题这类涉及加速度传感器数据处理和生理活动评估的题目,可能会感到一丝压力。数据是原始的、杂乱的,而问题却要求你从中提炼出清晰的健康洞察——比如计算不同强度活动的时长、预测能量消耗、甚至识别睡眠模式。这不仅仅是写几行代码那么简单,它考验的是你如何将信号处理、特征工程和机器学习模型有机地串联起来,构建一个完整的分析流水线。本文将以一个实战者的视角,抛开教科书式的理论罗列,直接切入核心,分享一套用Python和Matlab双引擎驱动,高效处理此类问题的思路、技巧与避坑指南。我们会从最“脏”的数据清洗开始,一步步走到具有预测和分类能力的模型,确保你拿到的不只是代码片段,而是一套可复用的方法论。
## 1. 数据预处理:从原始加速度信号到规整时间序列
拿到竞赛数据,尤其是像附件1这样的加速度计CSV文件时,第一感觉往往是“无从下手”。列名可能是中文,时间戳是毫秒级的巨大整数,而关键的MET值信息却嵌套在“标签”列的字符串里。跳过预处理直接建模?那几乎注定会失败。
### 1.1 时间戳的规范化与重采样
加速度数据通常以高频采集(例如几十赫兹),但我们的分析往往不需要如此精细的粒度。首先需要解决的是时间戳问题。原始数据中的“日期”列很可能是自某个纪元时间(如1970年1月1日)起的毫秒数。
```python
import pandas as pd
import numpy as np
def load_and_normalize_time(filepath):
"""
加载单个志愿者数据,将毫秒时间戳转换为易读的datetime格式,
并按时间排序,处理可能存在的乱序记录。
"""
df = pd.read_csv(filepath)
# 假设时间戳列名为‘日期’,单位为毫秒
df['datetime'] = pd.to_datetime(df['日期'], unit='ms')
df.sort_values('datetime', inplace=True)
df.reset_index(drop=True, inplace=True)
return df
```
一个常见的陷阱是直接使用`diff()`计算相邻点的时间差,但忽略了数据包可能丢失或采集频率不均的情况。更稳健的做法是,先统一采样频率。
```python
def resample_acceleration_data(df, target_freq='1S'):
"""
将不规则采样的加速度数据重采样到固定的频率(如1秒一次)。
使用均值聚合,以减少噪声并统一数据维度。
"""
# 设置datetime为索引
df.set_index('datetime', inplace=True)
# 对三轴加速度和后续提取的MET值进行重采样
resampled_df = df[['X', 'Y', 'Z']].resample(target_freq).mean()
# 前向填充可能因重采样产生的NaN(对于边缘情况)
resampled_df.ffill(inplace=True)
return resampled_df
```
> 注意:重采样的频率选择需要权衡。频率太高(如原始频率)计算量大且噪声多;频率太低(如1分钟)会丢失短时活动特征。对于人体活动分析,1秒到5秒的窗口是一个不错的起点。
### 1.2 MET值的信息提取与映射
题目中MET值藏在“标签”字符串里,如“MET值 1.2”。我们需要精准地把它“抠”出来。正则表达式是最佳工具,但务必考虑数据的健壮性。
```python
import re
def extract_met_from_label(label_series):
"""
从标签列中提取MET值。添加错误处理以应对格式意外的数据。
"""
met_list = []
for label in label_series:
if pd.isna(label):
met_list.append(np.nan)
continue
# 匹配‘MET值’后跟的数字(整数或小数)
match = re.search(r'MET值\s*([0-9]+(?:\.[0-9]+)?)', str(label))
if match:
met_list.append(float(match.group(1)))
else:
# 如果匹配失败,记录为NaN,后续可根据上下文插值
met_list.append(np.nan)
print(f"Warning: Could not extract MET from label: {label}")
return pd.Series(met_list, index=label_series.index)
```
将上述步骤整合,我们就得到了一个干净、等间隔、包含三轴加速度和MET值的时间序列数据框,这是所有后续分析的基础。
## 2. 特征工程:构建描述活动状态的“数字指纹”
有了干净的数据,下一步是创造模型能“理解”的特征。原始的三轴加速度点价值有限,我们需要从中衍生出能表征活动强度、模式和频率的指标。
### 2.1 基础时域与频域特征
对于每一个时间窗口(例如5秒),我们可以计算一组统计特征。这里不仅包括基本的均值、标准差,还包括一些对运动更敏感的指标。
```python
def calculate_time_domain_features(axis_data):
"""计算单个加速度轴的一系列时域特征。"""
features = {
'mean': np.mean(axis_data),
'std': np.std(axis_data),
'rms': np.sqrt(np.mean(np.square(axis_data))), # 均方根,反映整体信号能量
'max': np.max(axis_data),
'min': np.min(axis_data),
'range': np.ptp(axis_data), # 峰峰值
'percentile_25': np.percentile(axis_data, 25),
'percentile_75': np.percentile(axis_data, 75),
'skewness': pd.Series(axis_data).skew(), # 偏度,分布不对称性
'kurtosis': pd.Series(axis_data).kurtosis(), # 峰度,分布尖锐度
}
return features
```
除了单轴特征,**向量幅度(Vector Magnitude, VM)** 是一个至关重要的综合指标,它消除了设备方向的影响,直接反映身体活动的整体强度。
```python
vm = np.sqrt(df['X']**2 + df['Y']**2 + df['Z']**2)
```
频域特征能帮助我们区分周期性活动(如跑步)和非周期性活动(如打字)。通过快速傅里叶变换(FFT)来实现。
```python
def calculate_frequency_domain_features(axis_data, sampling_rate=1.0):
"""
计算频域特征。假设输入是单个窗口的数据,sampling_rate是重采样后的频率(Hz)。
"""
n = len(axis_data)
if n < 2:
return {'dominant_freq': 0, 'spectral_energy': 0}
fft_vals = np.fft.rfft(axis_data)
fft_freqs = np.fft.rfftfreq(n, d=1.0/sampling_rate)
fft_magnitude = np.abs(fft_vals)
# 找到幅度最大的频率成分(主频)
if len(fft_magnitude) > 0:
dominant_idx = np.argmax(fft_magnitude[1:]) + 1 # 忽略直流分量(0Hz)
dominant_freq = fft_freqs[dominant_idx]
else:
dominant_freq = 0
# 计算频谱能量(去除直流分量)
spectral_energy = np.sum(fft_magnitude[1:]**2)
return {
'dominant_freq': dominant_freq,
'spectral_energy': spectral_energy
}
```
### 2.2 特征表格与滑动窗口应用
我们需要用滑动窗口遍历整个时间序列,为每个窗口生成一个特征向量。下面这个表格概括了一个典型的特征集:
| 特征类别 | 具体特征 | 描述 | 物理意义 |
| :--- | :--- | :--- | :--- |
| **单轴时域** | X_mean, Y_std, Z_max等 | 各轴加速度的统计量 | 反映各方向上的运动幅度和波动 |
| **向量幅度(VM)** | VM_mean, VM_std, VM_rms | 合加速度的统计量 | 整体身体活动强度 |
| **频域** | X_dominant_freq, VM_spectral_energy | 主频率、频谱能量 | 识别活动的周期性和节奏 |
| **姿态相关** | `np.arctan2(Y, X)` 等 | 计算设备的俯仰、横滚角(需校准) | 粗略判断身体姿态(需谨慎使用) |
应用滑动窗口的代码框架如下:
```python
def extract_features_with_sliding_window(df, window_seconds=5, step_seconds=1):
"""
使用滑动窗口从数据框中提取特征。
window_seconds: 窗口大小(秒)
step_seconds: 滑动步长(秒),步长小于窗口可实现重叠采样,增加数据量。
"""
sampling_rate = 1.0 # 假设我们之前已重采样为1Hz
window_size = int(window_seconds * sampling_rate)
step_size = int(step_seconds * sampling_rate)
features_list = []
met_labels = []
for start_idx in range(0, len(df) - window_size + 1, step_size):
end_idx = start_idx + window_size
window_df = df.iloc[start_idx:end_idx]
# 计算特征
feat = {}
# 1. 计算三轴和VM的时域特征
for col in ['X', 'Y', 'Z']:
col_feat = calculate_time_domain_features(window_df[col].values)
for key, val in col_feat.items():
feat[f'{col}_{key}'] = val
vm = np.sqrt(window_df['X']**2 + window_df['Y']**2 + window_df['Z']**2)
vm_feat = calculate_time_domain_features(vm)
for key, val in vm_feat.items():
feat[f'VM_{key}'] = val
# 2. 计算频域特征(示例为X轴)
freq_feat = calculate_frequency_domain_features(window_df['X'].values, sampling_rate)
feat.update({f'X_{k}': v for k, v in freq_feat.items()})
# 3. 该窗口的MET值标签(取均值)
if 'MET' in window_df.columns:
label = window_df['MET'].mean()
met_labels.append(label)
features_list.append(feat)
features_df = pd.DataFrame(features_list)
return features_df, np.array(met_labels)
```
## 3. MET值分类与时长统计:定义活动强度剖面
问题一的核心是根据MET值对活动进行分类,并统计各类别的总时长。这看似简单,但边界条件的处理直接影响结果的准确性。
### 3.1 分类区间与边界处理
常用的MET分类标准(如ACSM指南)将活动分为睡眠、静态、低等、中等、高等强度。我们需要使用`pd.cut`函数,但要特别注意区间是左闭右开 `[left, right)` 还是左开右闭 `(left, right]`。题目中给出的区间(如`<1`, `1-1.6`)通常是左闭右开。
```python
def categorize_met_and_summarize(df):
"""
对包含MET值和时间差‘delta_h’的数据框进行分类和时长汇总。
"""
# 定义分类区间和标签
bins = [-np.inf, 1.0, 1.6, 3.0, 6.0, np.inf]
labels = ['睡眠', '静态活动', '低等强度', '中等强度', '高等强度']
# 使用pd.cut进行分类,right=False表示区间为[左,右)
df['activity_category'] = pd.cut(df['MET'],
bins=bins,
labels=labels,
right=False)
# 按类别汇总时间(假设‘delta_h’是每个记录点所代表的时间长度,单位:小时)
summary = df.groupby('activity_category')['delta_h'].sum()
# 转换为字典,并处理可能缺失的类别
result_dict = {cat: round(summary.get(cat, 0), 4) for cat in labels}
result_dict['记录总时长(小时)'] = round(df['delta_h'].sum(), 4)
return result_dict
```
### 3.2 处理毫秒时间戳与累积时长
原始数据点间隔可能不均匀,因此不能简单用“点数×固定间隔”来计算时长。我们需要累加每个点所代表的实际时间片。通常,一个数据点所代表的时间是其到下一个点的时间差。
```python
def calculate_time_deltas(df):
"""
计算相邻时间点之间的时间差(小时),并分配给前一个时间点。
最后一个点没有下一个点,通常被赋予一个估计值(如与前一个间隔相同)或直接舍弃。
"""
# 确保按时间排序
df.sort_values('datetime', inplace=True)
# 计算时间差,单位转换为小时
time_deltas = df['datetime'].diff().shift(-1) / pd.Timedelta(hours=1)
# 处理最后一个时间点:用前一个有效时间差填充,或置为NaN并在后续删除
time_deltas.iloc[-1] = time_deltas.iloc[-2] if len(time_deltas) > 1 else np.nan
df['delta_h'] = time_deltas
# 删除delta_h为NaN的行(通常是最后一行,如果未填充)
df.dropna(subset=['delta_h'], inplace=True)
return df
```
将以上所有步骤串联,你就能为每个志愿者生成一份精确的活动强度剖面报告。在Matlab中实现逻辑类似,主要利用`readtable`、`sortrows`、`diff`和`accumarray`函数。
## 4. 构建回归模型预测MET值
问题二要求我们根据加速度特征预测连续的MET值,这是一个典型的回归任务。特征已经在前面的步骤中准备好了,现在重点是模型选择、训练与评估。
### 4.1 模型选择与交叉验证
对于这种中小规模、特征可能具有复杂非线性关系的数据集,树模型(如随机森林、梯度提升树)通常比线性模型表现更好。我们以随机森林为例。
```python
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import KFold, cross_val_score
from sklearn.metrics import mean_absolute_error, r2_score
from sklearn.preprocessing import StandardScaler
def train_met_regression_model(features_df, labels, test_size=0.2):
"""
训练一个MET值回归模型,并返回模型和评估结果。
"""
# 划分训练集和测试集
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
features_df, labels, test_size=test_size, random_state=42
)
# 标准化特征(对树模型非必须,但有时有帮助)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
# 初始化模型
# n_estimators: 树的数量,越多越稳定,但计算量越大
# max_depth: 树的最大深度,控制模型复杂度,防止过拟合
model = RandomForestRegressor(n_estimators=200,
max_depth=10,
min_samples_split=5,
random_state=42,
n_jobs=-1) # 使用所有CPU核心
# 使用K折交叉验证在训练集上评估
kf = KFold(n_splits=5, shuffle=True, random_state=42)
cv_scores = cross_val_score(model, X_train_scaled, y_train,
cv=kf, scoring='r2')
print(f"5折交叉验证R²分数: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})")
# 在完整训练集上训练
model.fit(X_train_scaled, y_train)
# 在测试集上最终评估
y_pred = model.predict(X_test_scaled)
test_r2 = r2_score(y_test, y_pred)
test_mae = mean_absolute_error(y_test, y_pred)
print(f"测试集 R²: {test_r2:.4f}")
print(f"测试集 平均绝对误差(MAE): {test_mae:.4f}")
# 特征重要性分析
importances = model.feature_importances_
feat_imp_df = pd.DataFrame({
'feature': X_train.columns,
'importance': importances
}).sort_values('importance', ascending=False)
print("\nTop 10 重要特征:")
print(feat_imp_df.head(10))
return model, scaler, feat_imp_df
```
### 4.2 模型应用与结果输出
训练好模型后,将其应用到新的数据集(如附件2)上。记得对新数据做完全相同的预处理和特征提取流程。
```python
def predict_new_data(model, scaler, new_features_df):
"""
使用训练好的模型和标准化器预测新数据。
"""
new_features_scaled = scaler.transform(new_features_df)
predictions = model.predict(new_features_scaled)
return predictions
# 假设new_features是附件2数据经过相同流程提取的特征DataFrame
# met_predictions = predict_new_data(trained_model, fitted_scaler, new_features)
```
在Matlab中,可以使用`TreeBagger`函数来实现随机森林回归,其`oobPredict`属性可以方便地进行袋外误差估计,作为模型性能的参考。
## 5. 高级分析:睡眠模式聚类与久坐预警
问题三和四引入了更复杂的场景:无监督学习(聚类)和模式检测。
### 5.1 基于低活动量的睡眠时段检测与聚类
睡眠检测通常基于一个假设:睡眠时身体活动大幅减少。我们可以通过向量幅度(VM)的滑动平均来识别低活动窗口。
```python
from sklearn.cluster import KMeans
from scipy.signal import find_peaks
def detect_and_cluster_sleep(df, vm_threshold=0.1, window_sec=30, n_clusters=3):
"""
检测低活动窗口作为睡眠候选,并对这些窗口进行聚类以区分不同睡眠模式。
"""
# 计算VM
df['VM'] = np.sqrt(df['X']**2 + df['Y']**2 + df['Z']**2)
sampling_rate = 1.0
window_size = int(window_sec * sampling_rate)
# 计算滑动窗口平均VM
vm_rolling_mean = df['VM'].rolling(window=window_size, center=True, min_periods=1).mean()
# 找到VM均值低于阈值的所有连续区间
sleep_mask = vm_rolling_mean < vm_threshold
# 使用标签标记连续区间
from scipy.ndimage import label
labeled_array, num_features = label(sleep_mask)
sleep_segments = []
for seg_id in range(1, num_features + 1):
segment_indices = np.where(labeled_array == seg_id)[0]
if len(segment_indices) < window_size: # 过滤掉太短的区间
continue
segment_data = df.iloc[segment_indices]
# 提取该片段的特征,如VM的均值、标准差、分位数等
seg_features = [
segment_data['VM'].mean(),
segment_data['VM'].std(),
segment_data['VM'].quantile(0.25),
segment_data['VM'].quantile(0.75)
]
sleep_segments.append({
'indices': segment_indices,
'features': seg_features,
'duration_hours': len(segment_indices) / (3600 / sampling_rate)
})
if not sleep_segments:
return None, None
# 对睡眠片段的特征进行K-means聚类
features_matrix = np.array([seg['features'] for seg in sleep_segments])
kmeans = KMeans(n_clusters=min(n_clusters, len(sleep_segments)), random_state=42)
cluster_labels = kmeans.fit_predict(features_matrix)
# 将聚类标签分配回片段
for seg, label in zip(sleep_segments, cluster_labels):
seg['cluster'] = label
# 统计各聚类模式的总时长
total_duration = sum(seg['duration_hours'] for seg in sleep_segments)
mode_durations = np.zeros(n_clusters)
for seg in sleep_segments:
mode_durations[seg['cluster']] += seg['duration_hours']
return total_duration, mode_durations, sleep_segments
```
> 提示:`vm_threshold`的选择很关键,可能需要根据数据分布进行校准。可以绘制VM的分布直方图,观察低活动区域的拐点。
### 5.2 静态活动与久坐行为预警
问题四要求检测连续静态活动(MET < 1.6)超过30分钟的时段。这是一个状态检测问题。
```python
def detect_prolonged_sedentary_bouts(met_series, window_minutes=5, sedentary_threshold=1.6, prolonged_threshold_minutes=30):
"""
检测连续的久坐时段。
met_series: 按时间排序的MET值序列(可以是预测值)。
window_minutes: 计算MET均值的时间窗口(分钟)。
"""
sampling_interval_h = window_minutes / 60.0
sedentary_bouts = []
current_bout_start = None
current_bout_duration = 0.0
# 假设met_series已经是按固定时间间隔(如1分钟)采样的均值
for i, met in enumerate(met_series):
if met < sedentary_threshold:
if current_bout_start is None:
current_bout_start = i
current_bout_duration += sampling_interval_h
else:
if current_bout_start is not None:
if current_bout_duration >= (prolonged_threshold_minutes / 60.0):
sedentary_bouts.append({
'start_index': current_bout_start,
'end_index': i-1,
'duration_hours': current_bout_duration
})
current_bout_start = None
current_bout_duration = 0.0
# 检查最后一个时段
if current_bout_start is not None and current_bout_duration >= (prolonged_threshold_minutes / 60.0):
sedentary_bouts.append({
'start_index': current_bout_start,
'end_index': len(met_series)-1,
'duration_hours': current_bout_duration
})
return sedentary_bouts
```
这个函数会返回所有符合久坐条件的时段列表。你可以进一步将这些时段映射回具体的时间点,生成预警报告。
## 6. 工程化与效率提升:让代码跑得更快更稳
当需要处理成百上千个志愿者文件时,效率至关重要。这里有几个实战技巧:
- **向量化操作**:尽量避免在Pandas或NumPy中使用Python级别的循环。使用内置的向量化函数和`apply`方法。
- **并行处理**:使用`joblib`或`multiprocessing`库并行处理多个文件。
```python
from joblib import Parallel, delayed
def process_single_file(file_path):
# 封装单个文件的所有处理逻辑
df = load_and_normalize_time(file_path)
df = calculate_time_deltas(df)
df['MET'] = extract_met_from_label(df['标签'])
summary = categorize_met_and_summarize(df)
return summary
# 获取所有文件路径
file_paths = [f'附件1/P{vid}.csv' for vid in volunteer_ids]
# 并行处理
results = Parallel(n_jobs=-1)(delayed(process_single_file)(fp) for fp in file_paths)
# 合并结果
final_summary_df = pd.DataFrame(results)
```
- **内存管理**:对于超大文件,考虑使用分块读取(`pd.read_csv(chunksize=...)`)或更高效的数据格式,如Parquet。
- **缓存中间结果**:特征提取非常耗时。将提取好的特征保存为`feather`或`parquet`格式,避免重复计算。
整个流程走下来,你会发现数据挖掘竞赛的解题过程,就是一个将模糊的业务问题(“分析活动模式”)逐步拆解、转化为一系列具体的数据操作和建模任务的过程。从数据清洗的耐心,到特征工程的创意,再到模型调优的执着,每一步都影响着最终结果的质量。本文提供的代码和思路是一个坚实的起点,但真正的提升来自于你的实践、调试和对结果的深入思考。比如,为什么随机森林认为VM的均方根是最重要的特征?睡眠聚类的三个模式分别对应了什么样的体动特征?这些问题的答案,就藏在数据本身和你对业务的理解之中。