在问卷研究中,回收的数据难免混入大量无效样本:直线作答、秒填、规律选择、前后矛盾等。这些数据若不清理,会严重污染分析结果。本文提供一套完整的Python清洗流程,覆盖最常见的无效样本类型,并封装为可复用的类。
## 环境准备
需要 Python 3.8+,以及 pandas、numpy、matplotlib。安装命令:- pip install pandas numpy matplotlib openpyxl
复制代码 问卷星导出的数据通常结构为:一行一份问卷,一列一个题目,包含序号、提交时间、各题目答案。
## 数据加载与列识别
使用 pandas 读取 Excel 或 CSV 文件:- import pandas as pd
- import numpy as np
- df = pd.read_excel('问卷数据.xlsx')
- print(f"原始数据量: {len(df)} 份")
复制代码 自动识别题目列(包含 'Q'、'第'、'题' 等关键词)和时间列:- question_cols = [c for c in df.columns if c.startswith('Q') or '第' in str(c)]
- time_col = [c for c in df.columns if '时间' in str(c) or 'time' in str(c).lower()]
- print(f"题目列: {question_cols}")
- print(f"时间列: {time_col}")
复制代码
## 检测直线作答(Straight-lining)
所有题目选择完全相同的答案,是最常见的无效模式。以下函数检测完全一致的样本:- def detect_straight_lining(df, question_cols):
- invalid_indices = []
- for idx, row in df.iterrows():
- answers = row[question_cols].dropna().tolist()
- if len(answers) < 3:
- continue
- if len(set(answers)) == 1:
- invalid_indices.append(idx)
- return set(invalid_indices)
复制代码 为了避免误判(例如全部选“满意”可能是真实),增加接近直线检测:超过80%的答案相同。- from collections import Counter
- def detect_near_straight_lining(df, question_cols, threshold=0.8):
- invalid_indices = []
- for idx, row in df.iterrows():
- answers = row[question_cols].dropna().tolist()
- if len(answers) < 3:
- continue
- counts = Counter(answers)
- most_common_ratio = counts.most_common(1)[0][1] / len(answers)
- if most_common_ratio >= threshold:
- invalid_indices.append(idx)
- return set(invalid_indices)
复制代码
## 检测秒填(Speeders)
如果问卷包含答题时长列,直接按阈值过滤。常见规则:10题以内<10秒,10-20题<20秒,20题以上<30秒。- def detect_speeders(df, time_col, min_seconds=15):
- if not time_col:
- return set()
- duration_col = [c for c in df.columns if '时长' in str(c) or '用时' in str(c)]
- if duration_col:
- durations = pd.to_numeric(df[duration_col[0]], errors='coerce')
- return set(df[durations < min_seconds].index)
- return set()
复制代码
## 检测规律作答(Pattern Response)
如 1-2-3-4-5 的递增/递减序列,或 A-B-A-B 的交替模式。- def detect_pattern_response(df, question_cols):
- invalid_indices = []
- for idx, row in df.iterrows():
- answers = row[question_cols].dropna().tolist()
- if len(answers) < 5:
- continue
- try:
- numeric = pd.to_numeric(answers)
- except:
- if len(answers) >= 6:
- half = len(answers) // 2
- if answers[:half] == answers[half:half*2]:
- invalid_indices.append(idx)
- continue
- diffs = np.diff(numeric)
- if np.all(diffs == diffs[0]) and diffs[0] != 0:
- invalid_indices.append(idx)
- continue
- if len(numeric) >= 6:
- even_vals = set(numeric[::2])
- odd_vals = set(numeric[1::2])
- if len(even_vals) == 1 and len(odd_vals) == 1:
- invalid_indices.append(idx)
- return set(invalid_indices)
复制代码
## 检测矛盾回答
针对跳题逻辑,例如 Q1选“否”但后续题仍有内容。需要根据具体问卷编写:- def detect_contradictions(df, filter_col, filter_value, check_col):
- mask = df[filter_col] == filter_value
- should_be_empty = df.loc[mask, check_col]
- contradictions = should_be_empty[should_be_empty.notna() & (should_be_empty != '')]
- return set(contradictions.index)
复制代码
## 检测异常值(Z-score)
对量表题(1-5分或1-7分)计算每个样本的均值Z分数,超过阈值(通常3.0)视为异常:- def detect_outliers_zscore(df, question_cols, threshold=3.0):
- numeric_df = df[question_cols].apply(pd.to_numeric, errors='coerce')
- row_means = numeric_df.mean(axis=1)
- z_scores = (row_means - row_means.mean()) / row_means.std()
- return set(df[abs(z_scores) > threshold].index)
复制代码
## 汇总无效样本与标记
合并所有检测结果,并在DataFrame中添加标记列,导出清洗后的有效数据和无效明细:- all_invalid = straight_lining | near_straight | speeders | patterns | outliers
- df['is_invalid'] = df.index.isin(all_invalid)
- df['invalid_reason'] = ''
- df.loc[df.index.isin(straight_lining), 'invalid_reason'] += '直线作答;'
- df.loc[df.index.isin(near_straight), 'invalid_reason'] += '接近直线;'
- df.loc[df.index.isin(speeders), 'invalid_reason'] += '秒填;'
- df.loc[df.index.isin(patterns), 'invalid_reason'] += '规律作答;'
- df.loc[df.index.isin(outliers), 'invalid_reason'] += '统计异常;'
- clean_df = df[~df['is_invalid']].copy()
- clean_df.to_excel('问卷数据_已清洗.xlsx', index=False)
- invalid_df = df[df['is_invalid']].copy()
- invalid_df.to_excel('无效样本明细.xlsx', index=False)
复制代码
## 可视化清洗结果
使用matplotlib绘制无效类型柱状图和有效率饼图(需安装中文字体):- import matplotlib.pyplot as plt
- plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
- plt.rcParams['axes.unicode_minus'] = False
- reasons = {
- '直线作答': len(straight_lining),
- '接近直线': len(near_straight - straight_lining),
- '秒填': len(speeders),
- '规律作答': len(patterns),
- '统计异常': len(outliers),
- }
- fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
- ax1.bar(reasons.keys(), reasons.values(), color='#ff6b6b')
- ax1.set_title('无效样本类型分布')
- ax2.pie([len(clean_df), len(all_invalid)], labels=['有效样本', '无效样本'], autopct='%1.1f%%', colors=['#51cf66', '#ff6b6b'])
- ax2.set_title(f'样本有效率 (总计 {len(df)} 份)')
- plt.tight_layout()
- plt.show()
复制代码
## 封装为可复用的类
将上述函数整合成 SurveyDataCleaner 类,支持一键运行:- import pandas as pd
- import numpy as np
- from collections import Counter
- class SurveyDataCleaner:
- def __init__(self, filepath: str, question_cols: list = None):
- if filepath.endswith('.xlsx'):
- self.df = pd.read_excel(filepath)
- else:
- self.df = pd.read_csv(filepath)
- self.question_cols = question_cols or self._auto_detect_questions()
- self.invalid_sets = {}
- def _auto_detect_questions(self):
- return [c for c in self.df.columns if any(k in str(c) for k in ['Q', '第', '题'])]
- def run_all(self, min_seconds=15, z_threshold=3.0, straight_threshold=0.8):
- self.invalid_sets['直线作答'] = self._detect_straight()
- self.invalid_sets['接近直线'] = self._detect_near_straight(straight_threshold)
- self.invalid_sets['秒填'] = self._detect_speeders(min_seconds)
- self.invalid_sets['规律作答'] = self._detect_pattern()
- self.invalid_sets['统计异常'] = self._detect_outliers(z_threshold)
- return self
- def _detect_straight(self):
- invalid = set()
- for idx, row in self.df.iterrows():
- ans = row[self.question_cols].dropna().tolist()
- if len(ans) >= 3 and len(set(ans)) == 1:
- invalid.add(idx)
- return invalid
- def _detect_near_straight(self, threshold):
- invalid = set()
- for idx, row in self.df.iterrows():
- ans = row[self.question_cols].dropna().tolist()
- if len(ans) >= 3:
- counts = Counter(ans)
- if counts.most_common(1)[0][1] / len(ans) >= threshold:
- invalid.add(idx)
- return invalid
- def _detect_speeders(self, min_seconds):
- duration_col = [c for c in self.df.columns if '时长' in str(c) or '用时' in str(c)]
- if not duration_col:
- return set()
- durations = pd.to_numeric(self.df[duration_col[0]], errors='coerce')
- return set(self.df[durations < min_seconds].index)
- def _detect_pattern(self):
- invalid = set()
- for idx, row in self.df.iterrows():
- ans = row[self.question_cols].dropna().tolist()
- if len(ans) < 5:
- continue
- try:
- numeric = pd.array(ans, dtype='float')
- diffs = np.diff(numeric)
- if len(diffs) > 0 and np.all(diffs == diffs[0]) and diffs[0] != 0:
- invalid.add(idx)
- except:
- pass
- return invalid
- def _detect_outliers(self, threshold):
- numeric_df = self.df[self.question_cols].apply(pd.to_numeric, errors='coerce')
- row_means = numeric_df.mean(axis=1)
- z_scores = (row_means - row_means.mean()) / row_means.std()
- return set(self.df[abs(z_scores) > threshold].index)
- @property
- def all_invalid(self):
- result = set()
- for s in self.invalid_sets.values():
- result |= s
- return result
- @property
- def clean_data(self):
- return self.df[~self.df.index.isin(self.all_invalid)].copy()
- def report(self):
- total = len(self.df)
- invalid_count = len(self.all_invalid)
- print(f"\n{'='*50}")
- print(f"问卷数据清洗报告")
- print(f"{'='*50}")
- print(f"原始样本量: {total} 份")
- for reason, indices in self.invalid_sets.items():
- print(f" {reason}: {len(indices)} 份")
- print(f"{'─'*50}")
- print(f"无效样本合计: {invalid_count} 份")
- print(f"有效样本: {total - invalid_count} 份")
- print(f"有效率: {(1 - invalid_count/total)*100:.1f}%")
- print(f"{'='*50}")
- def export(self, output_path='清洗结果'):
- clean = self.clean_data
- clean.to_excel(f'{output_path}_有效数据.xlsx', index=False)
- invalid_df = self.df[self.df.index.isin(self.all_invalid)].copy()
- invalid_df['无效原因'] = ''
- for reason, indices in self.invalid_sets.items():
- invalid_df.loc[invalid_df.index.isin(indices), '无效原因'] += f'{reason};'
- invalid_df.to_excel(f'{output_path}_无效样本.xlsx', index=False)
- if __name__ == '__main__':
- cleaner = SurveyDataCleaner('问卷数据.xlsx')
- cleaner.run_all(min_seconds=15, z_threshold=3.0)
- cleaner.report()
- cleaner.export()
复制代码
## 关于样本质量的延伸
清洗后有效样本量可能不足。若无效率超过20%,建议检查数据来源。例如,相对于互填群,使用真实用户渠道(如问卷鸭等)的无效率通常更低。但无论来源如何,清洗流程不可或缺。以上代码覆盖了直线作答、秒填、规律模式、矛盾回答和统计异常等常见场景,可用于学术调研、市场研究等。如需进一步信效度分析,可结合 factor_analyzer 库。 |