前言
之前学习了贝叶斯更新的相关内容,正好现在也在玩开发板,板子上面有几个小的单击按键,一般识别按键动作的做法就很简单,不是中断就是查询,基本都是靠边沿或者电平的状态来进行的,这一套就很无聊,没有实现的欲望,所以想用点不一样的方法。
这就有了本片文章的出现,基于朴素贝叶斯分类,使用滑动窗口捕捉电平序列,提取特征进行模式识别,理想情况下识别效果杠杠的,但是出现边界以及混合的情况,效果一言难尽,目前水平不够,这应该也是后续需要解决的主要问题了。
技术要点
核心原理
- 贝叶斯定理
本文实现的方法基于朴素贝叶斯分类器,主要就是两方面内容:贝叶斯定理与条件独立假设,涉及的概念有先验概率 、后验概率 和条件概率,其中先验和条件概率都是提前准备好的,可以是主观经验的,也可以是统计量化的,而贝叶斯定理中的条件概率(不是后验概率),又称为似然概率。
这个方法的基本思想是:对于给定的待分类项(就是窗口中的电平序列),求解当这个待分类项出现时,各个已经定义过的模式类别出现的概率,哪个概率最大,那么这个待分类项就属于哪个模式。
在开始分类之前需要一些必要的准备工作:
- 定义有哪些模式类别,这些模式边界要明确,不然不容易分析特征
- 定义这些模式的特征属性,这些属性在不同模式下的表现是不同的,这是识别的关键,对应了贝叶斯定理中的似然概率
- 滑动窗口
这里的窗口是实时更新的窗口,老数据移出,新数据加入,滑动窗口确定电平序列数据的范围,只有处在窗口中的序列数据才会得到特征提取的机会,它的长度与序列的时间长度成比例,也就是说采样频率会影响到窗口时效性。
它需要考虑的问题是怎么捕捉到完整的信号,对应于滑动的步长,以及特征提取的周期。
基本步骤
通过以下步骤实现按键动作模式识别:
- 滑动窗口采集:使用固定大小的滑动窗口持续采集按键状态数据
- 特征提取:从窗口数据中提取多个维度的特征
- 概率计算 :基于先验概率和似然概率计算后验概率
- 模式判断 :根据后验概率和阈值确定当前按键模式
具体实现
为了验证设想的可行性,通过逻辑分析仪记录按键的引脚电平变化,低电平表示按键按下,高电平表示无按键动作,采样率1MHz,时长20s,在后面的实验中,认为序列是连续的,这就是电平序列的来源,具体序列如下图所示:
上面记录的数据可以作为一个样本,我通过观察和测量确定了几种模式,以及一些帮助识别的特征属性,在实验过程中使用python进行了方法验证。
模式定义
我在设计过程中定义了四种按键模式,分别如下:
- 无效:无有效按键动作
- 单击:单次短暂按键动作
- 双击:快速连续两次按键动作
- 长按:持续时间较长的按键动作
动作的实施都是通过一个单按键来进行的,其中单击和双击涉及到电平的较快速变化,是识别的难点
特征选择
基于对提取的特征包括:
- 高电平占比:窗口内高电平信号的比例
- 上升沿数量:信号从低到高的转换次数
- 下降沿数量:信号从高到低的转换次数
- 最长连续高电平持续时间:窗口内持续高电平的最长时间
概率模型
- 先验概率 :初始假设四种模式等概率出现,即每个模式的先验都是0.25。并且和一般的贝叶斯方法不同的是,在实现过程中认为先验是不需要更新的,也就是在每一次识别时认为每个模式都是等概率出现的,没有转移概率或者历史因素影响
- 似然概率 :基于特征分布参数 计算观测到当前特征的概率,其中的分布参数是根据实际捕捉的序列数据来设计的,概率分布模型采用正态分布来近似 ,需要均值和标准差,统一使用概率密度 表达似然结果
- 高电平占比的分布参数
- 无效:0.05,0.2
- 单击:0.2,0.2
- 双击:0.3,0.2
- 长按:0.9,0.2
- (上升沿/下降沿)数量的分布参数
- 无效:0.1,0.3
- 单击:1,0.3
- 双击:2,0.3
- 长按:0.7,0.3
- 最长高电平持续时间的分布参数
- 无效:0,2
- 单击:0.2,5
- 双击:0.17,3
- 长按:0.9,10
- 高电平占比的分布参数
- 后验概率 :使用贝叶斯公式计算各模式的后验概率,先计算提取的特征在每个模式下的联合似然,基于条件独立假设,可以直接相乘,然后计算后验并归一化可得最终的概率表
代码实现
- 数据采集与预处理
把逻辑分析仪中的数据导出为csv文件,代码首先实现了 read_sigrok_csv_simple 函数,用于读取 sigrok CSV 格式的按键数据:
python
def read_sigrok_csv_simple(filename):
time_data = []
signal_data = []
with open(filename, 'r', newline='') as csvfile:
reader = csv.reader(csvfile)
for row in reader:
# 跳过注释行和空行
if not row or row[0].startswith(';'):
continue
# 确保行有两个列
if len(row) >= 2:
try:
time_val = float(row[0])
data_val = float(row[1])
time_data.append(time_val)
signal_data.append(data_val)
except ValueError:
# 跳过无法转换为数字的行
continue
return time_data, signal_data
该函数读取 CSV 文件中的时间戳和信号值,返回两个列表分别存储时间数据和信号数据,通过plot输出采样的数据图如下所示:
- 识别器类设计
核心实现是 BayesianButtonRecognizer 类,用于实现基于贝叶斯分类的按键模式识别:
python
class BayesianButtonRecognizer:
"""基于滑动窗口和贝叶斯更新的按键模式识别器"""
def __init__(self, window_size=20, sample_interval=0.01,
threshold=0.7):
"""
初始化识别器
Args:
window_size: 滑动窗口大小
sample_interval: 采样间隔(秒)
threshold: 判定阈值
"""
self.window_size = window_size
self.sample_interval = sample_interval
self.threshold = threshold
# 滑动窗口存储最近的观测序列
self.window = deque(maxlen=window_size)
# 模式类别
self.modes = ['无效', '单击', '双击', '长按']
# 先验概率 - 初始等可能
self.prior = np.array([0.25, 0.25, 0.25, 0.25])
# 特征提取相关的参数(单位:采样点数)
self.short_press_max = 15 # 短按最大持续时间
self.long_press_min = 30 # 长按最小持续时间
self.double_click_interval = 10 # 双击间隔阈值
# 初始化特征分布参数(基于物理理解预设)
self._init_feature_distributions()
# 特征权重
self.featwight={
"无效":np.array([1.2,0.8,0.8,1.2]),
"单击":np.array([1,1.2,1.2,1]),
"双击":np.array([1,1.2,1.2,0.8]),
"长按":np.array([1.2,0.8,0.8,1.2])
}
- 特征分布初始化
识别器初始化时设置了各模式下特征的概率分布参数:
python
def _init_feature_distributions(self):
"""初始化各模式下特征的概率分布参数"""
# 高电平占比的分布参数
self.high_ratio_params = {
'无效': 0.05, # 无效时高电平占比很低
'单击': 0.2, # 单击时有短暂高电平
'双击': 0.3, # 双击时高电平占比稍高
'长按': 0.9 # 长按时高电平占比很高
}
# 上升沿数量的分布参数
self.rise_count_params = {
'无效': 0.1, # 无效时几乎无上升沿
'单击': 1, # 单击时有1个上升沿
'双击': 2, # 双击时有2个上升沿
'长按': 0.7 # 长按有1个上升沿
}
# 最长高电平持续时间的分布参数(正态分布:均值,标准差)
self.max_duration_params = {
'无效': (0, 2), # 无效时持续时间很短
'单击': (0.2, 5), # 单击中等持续时间
'双击': (0.17, 3), # 双击每次按下时间短
'长按': (0.9, 10) # 长按持续时间长
}
- 特征提取
从滑动窗口数据中提取特征,其中高电平占比是通过求序列平均值来获得的,然后边沿计数对应了记录序列跳变数量,最长高电平时间通过记录连续高电平时长获取:
python
def extract_features(self, window_data):
"""从滑动窗口数据中提取特征"""
if len(window_data) == 0:
return None
data = np.array(window_data)
# 特征1: 高电平占比
high_ratio = np.mean(data)
# 特征2: 上升沿数量(0->1的变化)
rises = 0
for i in range(1, len(data)):
if data[i-1] == 0 and data[i] == 1:
rises += 1
# 特征3: 下降沿数量(1->0的变化)
falls = 0
for i in range(1, len(data)):
if data[i-1] == 1 and data[i] == 0:
falls += 1
# 特征4: 最长连续高电平持续时间
max_duration = 0
current_duration = 0
for val in data:
if val == 1:
current_duration += 1
max_duration = max(max_duration, current_duration)
else:
current_duration = 0
return {
'high_ratio': high_ratio,
'rise_count': rises,
'fall_count': falls,
'max_duration': max_duration
}
- 似然概率计算
计算给定模式下观测到特征值的似然概率,即条件概率,通过上面定义的分布参数,使用正态分布近似,在python中通过stats.norm.pdf求特征对应每个模式的似然程度,然后基于条件独立的假设,求解联合似然,表示样本对某一模式的最终似然结果:
python
def calculate_likelihood(self, features, mode):
"""计算给定模式下观测到特征值的似然概率"""
if features is None:
return 1.0 # 无特征时返回中性似然
# 使用概率密度函数计算各特征的似然
likelihoods = []
# 1. 高电平占比的似然
target_ratio = self.high_ratio_params[mode]
# 使用正态分布近似, 标准差根据经验设定
like_ratio = stats.norm.pdf(features['high_ratio'],
target_ratio, 0.2)
likelihoods.append(like_ratio + 1e-10) # 避免零
# 2. 上升沿数量的似然
target_rises = self.rise_count_params[mode]
like_rises = stats.norm.pdf(features['rise_count'],
target_rises,0.3)
likelihoods.append(like_rises + 1e-10)
# 3. 下降沿(同上升沿)数量的似然
target_falls = self.rise_count_params[mode]
like_falls = stats.norm.pdf(features['fall_count'],
target_falls,0.3)
likelihoods.append(like_falls + 1e-10)
# 4. 最长持续时间的似然(使用正态分布)
target_dur, std_dur = self.max_duration_params[mode]
target_dur *= self.window_size
like_duration = stats.norm.pdf(features['max_duration'],
target_dur, std_dur)
likelihoods.append(like_duration + 1e-10)
# 组合各特征的似然(假设特征条件独立)
total_likelihood = np.prod(np.array(likelihoods))
print("特征在mode[%s]的似然:"%{mode},likelihoods,"最终联合似然:%.
3f"%total_likelihood)
return total_likelihood
- 滑动窗口更新
python
def slide_window(self,io_state):
# 移除最旧的值
self.window.popleft()
# 将新观测值加入滑动窗口
self.window.append(io_state)
- 信念更新与模式判断
计算完样本对每个模式的似然后,就于先验概率相乘,就得到了后验概率,然后归一化得到最终结果,同时使用阈值判定机制,当最大后验超过判定阈值后,才会识别具体模式,否则就是不确定
python
def update_belief(self, io_state):
"""根据新观测值更新信念"""
# 提取当前窗口的特征
features = self.extract_features(self.window)
print("特征提取:",features)
# 计算各模式的似然
likelihoods = np.array([self.calculate_likelihood(features,
mode)
for mode in self.modes])
# 贝叶斯更新: 后验 ∝ 似然 × 先验
unnormalized_posterior = likelihoods * self.prior
evidence = np.sum(unnormalized_posterior)
if evidence > 0:
posterior = unnormalized_posterior / evidence
else:
posterior = self.prior.copy()
# 更新先验(用于下一次迭代)
# self.prior = posterior
# 判断当前模式
best_mode_idx = np.argmax(posterior)
best_prob = posterior[best_mode_idx]
print("后验:",posterior)
if best_prob > self.threshold:
detected_mode = self.modes[best_mode_idx]
else:
detected_mode = '不确定'
return detected_mode, posterior
- 主函数与演示
因为定义了高电平为有效电平,但实际中低电平,或者说下降沿是按键动作的反应,所以处理数据序列时做了相应的取反处理。
python
if __name__ == "__main__":
DeltaT = 0.01 # 采样间隔
UnitTime = 1e-06 # 原始数据点的时基
SampleInterval = math.floor(DeltaT / UnitTime)
filename = "key_data_20s_all.csv" # 逻辑分析仪导出的数据
recognizer = BayesianButtonRecognizer(window_size=100,
threshold=0.8)
recognizer.reset()
time_data, signal_data = read_sigrok_csv_simple(filename)
print(f"成功读取数据,共 {len(time_data)} 个数据点")
print(f"时间范围: {time_data[0]}s 到 {time_data[-1]}s")
plt.figure(1)
sample_data = []
res_data = []
sample_num = math.floor(len(signal_data) / SampleInterval)
print("sample size is:",sample_num)
for i in range(sample_num-1):
sample_data.append(int(not signal_data[SampleInterval*i]))
recognizer.slide_window(int(not signal_data
[SampleInterval*i]))
if i%recognizer.window_size==0:
res,postrior=recognizer.update_belief(i)
if(res not in["不确定","无效"]):
res_data.append(res)
print("win[%d]:"%i,res)
plt.plot(recognizer.window)
plt.show()
plt.figure(1)
plt.plot(sample_data)
plt.show()
print(res_data)
当窗口中样本序列是理想情况时,识别效果相当好:
无效样本示例:
上图是一个无效按键样本序列图,保持无效电平,没有边沿变化。下图给出了识别的过程和结果:
可以看到特征提取的信息是正确的,高电平占比为0,边沿计数为0,最长高电平延时为0,在各个模式的似然列表中,给出了对应的似然结果,同时从列数据对比来看,也可以直接从数值上看出样本特征更偏向哪个模式,最终的后验结果,确实是无效模式的概率最高,即判定窗口中的序列为无效。
单击样本示例:
上图是一个单击按键样本序列图,有边沿变化,一个上升沿,一个下降沿,高电平占比大约0.2。下图给出了识别的过程和结果:
可以看到特征提取的信息是正确的,最终的识别结果也是正确的
双击样本示例:
上图是一个双击样本的示例图,可以看到由两个高电平组成,下图给出识别过程和结果:
可以看出特征提取信息正确,有两个上升沿和两个下降沿,然后最终的后验概率中也是双击的概率最大,并且超过阈值判定正确。
下面给出一些因为信号完整性缺失造成的误判示例。
边界双击情况示例:
上图中可以看出很明显是一个双击的动作,但是由于窗口长度固定的原因,导致一部分序列缺失,下图给出识别结果:
特征提取的信息倒是正确的,识别出下降沿只有1个,在计算似然过程中,相应位置的似然结果也反应了这一点,最终的后验表中可以看到前两个大的概率是单击和双击,但是都没超过阈值,所以判定为不确定
边界单击情况示例:
可以看出这个情况像是单击,但是实际上是一段长按序列,下图给出识别过程:
特征信息提取是正确的,然后似然结果都偏低,表示不偏向某一个模式,但在最终的后验结果中单击的后验概率异常的高,应该是在归一化过程中,单击概率占比比其他概率大很多导致的,这也是同样的问题,也就是信号完整性缺失导致了误判
总结
在这次实验中,基于朴素贝叶斯分类方法,通过滑动窗口采集数据、提取多维度特征、计算概率分布和应用贝叶斯更新,学到了不少,也融合了很多内容,算是一次不小的学习体验吧,虽然目前测试下来效果有限,还无法真正用在项目中,也总结了一些不足的地方。
比如信号完整性保证不了,不同特征属性对不同模式的权重实际并不一致等,这些都是需要解决的问题,虽然对现在的我来说很困难,但探索新方法的过程还是蛮喜欢的,也可能是对现有方法的审美疲劳导致的吧。
但有一说一,传统的方法,还是简单高效的,也不涉及到什么数学的内容,全凭逻辑加判断就可以搞定了,真是省时省力啊。