查看: 91|回复: 3

Python问卷数据清洗实战:直线作答秒填规律异常检测代

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
在问卷研究中,回收的数据难免混入大量无效样本:直线作答、秒填、规律选择、前后矛盾等。这些数据若不清理,会严重污染分析结果。本文提供一套完整的Python清洗流程,覆盖最常见的无效样本类型,并封装为可复用的类。

## 环境准备
需要 Python 3.8+,以及 pandas、numpy、matplotlib。安装命令:
  1. pip install pandas numpy matplotlib openpyxl
复制代码
问卷星导出的数据通常结构为:一行一份问卷,一列一个题目,包含序号、提交时间、各题目答案。

## 数据加载与列识别
使用 pandas 读取 Excel 或 CSV 文件:
  1. import pandas as pd
  2. import numpy as np
  3. df = pd.read_excel('问卷数据.xlsx')
  4. print(f"原始数据量: {len(df)} 份")
复制代码
自动识别题目列(包含 'Q'、'第'、'题' 等关键词)和时间列:
  1. question_cols = [c for c in df.columns if c.startswith('Q') or '第' in str(c)]
  2. time_col = [c for c in df.columns if '时间' in str(c) or 'time' in str(c).lower()]
  3. print(f"题目列: {question_cols}")
  4. print(f"时间列: {time_col}")
复制代码

## 检测直线作答(Straight-lining)
所有题目选择完全相同的答案,是最常见的无效模式。以下函数检测完全一致的样本:
  1. def detect_straight_lining(df, question_cols):
  2.     invalid_indices = []
  3.     for idx, row in df.iterrows():
  4.         answers = row[question_cols].dropna().tolist()
  5.         if len(answers) < 3:
  6.             continue
  7.         if len(set(answers)) == 1:
  8.             invalid_indices.append(idx)
  9.     return set(invalid_indices)
复制代码
为了避免误判(例如全部选“满意”可能是真实),增加接近直线检测:超过80%的答案相同。
  1. from collections import Counter
  2. def detect_near_straight_lining(df, question_cols, threshold=0.8):
  3.     invalid_indices = []
  4.     for idx, row in df.iterrows():
  5.         answers = row[question_cols].dropna().tolist()
  6.         if len(answers) < 3:
  7.             continue
  8.         counts = Counter(answers)
  9.         most_common_ratio = counts.most_common(1)[0][1] / len(answers)
  10.         if most_common_ratio >= threshold:
  11.             invalid_indices.append(idx)
  12.     return set(invalid_indices)
复制代码

## 检测秒填(Speeders)
如果问卷包含答题时长列,直接按阈值过滤。常见规则:10题以内<10秒,10-20题<20秒,20题以上<30秒。
  1. def detect_speeders(df, time_col, min_seconds=15):
  2.     if not time_col:
  3.         return set()
  4.     duration_col = [c for c in df.columns if '时长' in str(c) or '用时' in str(c)]
  5.     if duration_col:
  6.         durations = pd.to_numeric(df[duration_col[0]], errors='coerce')
  7.         return set(df[durations < min_seconds].index)
  8.     return set()
复制代码

## 检测规律作答(Pattern Response)
如 1-2-3-4-5 的递增/递减序列,或 A-B-A-B 的交替模式。
  1. def detect_pattern_response(df, question_cols):
  2.     invalid_indices = []
  3.     for idx, row in df.iterrows():
  4.         answers = row[question_cols].dropna().tolist()
  5.         if len(answers) < 5:
  6.             continue
  7.         try:
  8.             numeric = pd.to_numeric(answers)
  9.         except:
  10.             if len(answers) >= 6:
  11.                 half = len(answers) // 2
  12.                 if answers[:half] == answers[half:half*2]:
  13.                     invalid_indices.append(idx)
  14.             continue
  15.         diffs = np.diff(numeric)
  16.         if np.all(diffs == diffs[0]) and diffs[0] != 0:
  17.             invalid_indices.append(idx)
  18.             continue
  19.         if len(numeric) >= 6:
  20.             even_vals = set(numeric[::2])
  21.             odd_vals = set(numeric[1::2])
  22.             if len(even_vals) == 1 and len(odd_vals) == 1:
  23.                 invalid_indices.append(idx)
  24.     return set(invalid_indices)
复制代码

## 检测矛盾回答
针对跳题逻辑,例如 Q1选“否”但后续题仍有内容。需要根据具体问卷编写:
  1. def detect_contradictions(df, filter_col, filter_value, check_col):
  2.     mask = df[filter_col] == filter_value
  3.     should_be_empty = df.loc[mask, check_col]
  4.     contradictions = should_be_empty[should_be_empty.notna() & (should_be_empty != '')]
  5.     return set(contradictions.index)
复制代码

## 检测异常值(Z-score)
对量表题(1-5分或1-7分)计算每个样本的均值Z分数,超过阈值(通常3.0)视为异常:
  1. def detect_outliers_zscore(df, question_cols, threshold=3.0):
  2.     numeric_df = df[question_cols].apply(pd.to_numeric, errors='coerce')
  3.     row_means = numeric_df.mean(axis=1)
  4.     z_scores = (row_means - row_means.mean()) / row_means.std()
  5.     return set(df[abs(z_scores) > threshold].index)
复制代码

## 汇总无效样本与标记
合并所有检测结果,并在DataFrame中添加标记列,导出清洗后的有效数据和无效明细:
  1. all_invalid = straight_lining | near_straight | speeders | patterns | outliers
  2. df['is_invalid'] = df.index.isin(all_invalid)
  3. df['invalid_reason'] = ''
  4. df.loc[df.index.isin(straight_lining), 'invalid_reason'] += '直线作答;'
  5. df.loc[df.index.isin(near_straight), 'invalid_reason'] += '接近直线;'
  6. df.loc[df.index.isin(speeders), 'invalid_reason'] += '秒填;'
  7. df.loc[df.index.isin(patterns), 'invalid_reason'] += '规律作答;'
  8. df.loc[df.index.isin(outliers), 'invalid_reason'] += '统计异常;'
  9. clean_df = df[~df['is_invalid']].copy()
  10. clean_df.to_excel('问卷数据_已清洗.xlsx', index=False)
  11. invalid_df = df[df['is_invalid']].copy()
  12. invalid_df.to_excel('无效样本明细.xlsx', index=False)
复制代码

## 可视化清洗结果
使用matplotlib绘制无效类型柱状图和有效率饼图(需安装中文字体):
  1. import matplotlib.pyplot as plt
  2. plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
  3. plt.rcParams['axes.unicode_minus'] = False
  4. reasons = {
  5.     '直线作答': len(straight_lining),
  6.     '接近直线': len(near_straight - straight_lining),
  7.     '秒填': len(speeders),
  8.     '规律作答': len(patterns),
  9.     '统计异常': len(outliers),
  10. }
  11. fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
  12. ax1.bar(reasons.keys(), reasons.values(), color='#ff6b6b')
  13. ax1.set_title('无效样本类型分布')
  14. ax2.pie([len(clean_df), len(all_invalid)], labels=['有效样本', '无效样本'], autopct='%1.1f%%', colors=['#51cf66', '#ff6b6b'])
  15. ax2.set_title(f'样本有效率 (总计 {len(df)} 份)')
  16. plt.tight_layout()
  17. plt.show()
复制代码

## 封装为可复用的类
将上述函数整合成 SurveyDataCleaner 类,支持一键运行:
  1. import pandas as pd
  2. import numpy as np
  3. from collections import Counter
  4. class SurveyDataCleaner:
  5.     def __init__(self, filepath: str, question_cols: list = None):
  6.         if filepath.endswith('.xlsx'):
  7.             self.df = pd.read_excel(filepath)
  8.         else:
  9.             self.df = pd.read_csv(filepath)
  10.         self.question_cols = question_cols or self._auto_detect_questions()
  11.         self.invalid_sets = {}
  12.     def _auto_detect_questions(self):
  13.         return [c for c in self.df.columns if any(k in str(c) for k in ['Q', '第', '题'])]
  14.     def run_all(self, min_seconds=15, z_threshold=3.0, straight_threshold=0.8):
  15.         self.invalid_sets['直线作答'] = self._detect_straight()
  16.         self.invalid_sets['接近直线'] = self._detect_near_straight(straight_threshold)
  17.         self.invalid_sets['秒填'] = self._detect_speeders(min_seconds)
  18.         self.invalid_sets['规律作答'] = self._detect_pattern()
  19.         self.invalid_sets['统计异常'] = self._detect_outliers(z_threshold)
  20.         return self
  21.     def _detect_straight(self):
  22.         invalid = set()
  23.         for idx, row in self.df.iterrows():
  24.             ans = row[self.question_cols].dropna().tolist()
  25.             if len(ans) >= 3 and len(set(ans)) == 1:
  26.                 invalid.add(idx)
  27.         return invalid
  28.     def _detect_near_straight(self, threshold):
  29.         invalid = set()
  30.         for idx, row in self.df.iterrows():
  31.             ans = row[self.question_cols].dropna().tolist()
  32.             if len(ans) >= 3:
  33.                 counts = Counter(ans)
  34.                 if counts.most_common(1)[0][1] / len(ans) >= threshold:
  35.                     invalid.add(idx)
  36.         return invalid
  37.     def _detect_speeders(self, min_seconds):
  38.         duration_col = [c for c in self.df.columns if '时长' in str(c) or '用时' in str(c)]
  39.         if not duration_col:
  40.             return set()
  41.         durations = pd.to_numeric(self.df[duration_col[0]], errors='coerce')
  42.         return set(self.df[durations < min_seconds].index)
  43.     def _detect_pattern(self):
  44.         invalid = set()
  45.         for idx, row in self.df.iterrows():
  46.             ans = row[self.question_cols].dropna().tolist()
  47.             if len(ans) < 5:
  48.                 continue
  49.             try:
  50.                 numeric = pd.array(ans, dtype='float')
  51.                 diffs = np.diff(numeric)
  52.                 if len(diffs) > 0 and np.all(diffs == diffs[0]) and diffs[0] != 0:
  53.                     invalid.add(idx)
  54.             except:
  55.                 pass
  56.         return invalid
  57.     def _detect_outliers(self, threshold):
  58.         numeric_df = self.df[self.question_cols].apply(pd.to_numeric, errors='coerce')
  59.         row_means = numeric_df.mean(axis=1)
  60.         z_scores = (row_means - row_means.mean()) / row_means.std()
  61.         return set(self.df[abs(z_scores) > threshold].index)
  62.     @property
  63.     def all_invalid(self):
  64.         result = set()
  65.         for s in self.invalid_sets.values():
  66.             result |= s
  67.         return result
  68.     @property
  69.     def clean_data(self):
  70.         return self.df[~self.df.index.isin(self.all_invalid)].copy()
  71.     def report(self):
  72.         total = len(self.df)
  73.         invalid_count = len(self.all_invalid)
  74.         print(f"\n{'='*50}")
  75.         print(f"问卷数据清洗报告")
  76.         print(f"{'='*50}")
  77.         print(f"原始样本量: {total} 份")
  78.         for reason, indices in self.invalid_sets.items():
  79.             print(f"  {reason}: {len(indices)} 份")
  80.         print(f"{'─'*50}")
  81.         print(f"无效样本合计: {invalid_count} 份")
  82.         print(f"有效样本: {total - invalid_count} 份")
  83.         print(f"有效率: {(1 - invalid_count/total)*100:.1f}%")
  84.         print(f"{'='*50}")
  85.     def export(self, output_path='清洗结果'):
  86.         clean = self.clean_data
  87.         clean.to_excel(f'{output_path}_有效数据.xlsx', index=False)
  88.         invalid_df = self.df[self.df.index.isin(self.all_invalid)].copy()
  89.         invalid_df['无效原因'] = ''
  90.         for reason, indices in self.invalid_sets.items():
  91.             invalid_df.loc[invalid_df.index.isin(indices), '无效原因'] += f'{reason};'
  92.         invalid_df.to_excel(f'{output_path}_无效样本.xlsx', index=False)
  93. if __name__ == '__main__':
  94.     cleaner = SurveyDataCleaner('问卷数据.xlsx')
  95.     cleaner.run_all(min_seconds=15, z_threshold=3.0)
  96.     cleaner.report()
  97.     cleaner.export()
复制代码

## 关于样本质量的延伸
清洗后有效样本量可能不足。若无效率超过20%,建议检查数据来源。例如,相对于互填群,使用真实用户渠道(如问卷鸭等)的无效率通常更低。但无论来源如何,清洗流程不可或缺。以上代码覆盖了直线作答、秒填、规律模式、矛盾回答和统计异常等常见场景,可用于学术调研、市场研究等。如需进一步信效度分析,可结合 factor_analyzer 库。
回复

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python问卷数据清洗实战:直线作答秒填规律异常检测代

感谢分享,这套清洗流程很实用,尤其是近直线检测和交替模式判断,考虑到了真实填写中可能出现的模糊情况。想请教一下,对于混合型问卷(既有评分题又有选择题),你是如何区分哪些列适用于这些检测逻辑的?另外,如果数据量很大(比如上万份),直接用循环逐行遍历会不会影响效率,有没有考虑用向量化操作来优化?
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python问卷数据清洗实战:直线作答秒填规律异常检测代

非常实用的分享!最近正好在处理一批问卷数据,直线作答和秒填确实是最头疼的问题。你提供的检测函数思路清晰,尤其是“接近直线检测”用占比阈值来避免误判,很合理。 想请教一下:在实际应用中,不同题目类型的取值范围差异较大(比如李克特5点量表 vs 多选题),你会建议针对不同的题目块分别设定阈值吗?另外,如果问卷中包含矩阵题(比如一行多题),题目列识别时是否需要特殊处理?期待继续补充逻辑矛盾检测的部分~
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python问卷数据清洗实战:直线作答秒填规律异常检测代

感谢分享!代码框架很清晰,直接复制就能跑起来,对处理问卷数据非常实用。我之前也遇到过直线作答和规律填答的问题,手动筛选太费时了,你这个封装成类的方式很棒。想请教一下,在实际项目中,如果问卷包含多选题(列名不规则),是否需要对question_cols的识别逻辑做额外处理?另外对于秒填检测,如果数据里没有单独的时长列,只靠提交时间计算间隔可行吗?希望多交流。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-7-9 13:59 , Processed in 0.031373 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部