Python + pandas 问卷数据自动化清洗与统计分析实战
简介2024年使用Python开展调查问卷数据统计的资料包面向学生、数据分析初学者及开发人员解决问卷回收后的数据清洗、汇总、交叉分析与结果可视化等问题。压缩包共129个文件总大小约185.63MB包含约40份演示文稿、25份Word文档doc与docx、15份Markdown笔记、11份PDF手册以及少量表格、程序脚本、演示视频、图片等。演示文稿侧重讲解统计思路与步骤笔记适合快速查阅关键要点表格提供可替换的示例数据脚本可直接运行或改造视频展示完整操作流程从数据导入、频数统计到图表输出均有相应材料支撑使用者可按需取用。资料中还附有可行性研究报告、项目开发计划、概要设计说明书等软件工程模板和测试用例编写规范帮助读者按工程化节奏推进问卷统计项目并获得可复用的文档与代码模板。已有208人学习或下载适合希望对照实际样例快速搭建统计流程、掌握规范化分析方法的中初级Python使用者。1. 调查问卷数据统计一张 Excel 表格里藏着 80% 的脏数据做问卷调研最折腾的不是发问卷是收回来之后那一堆乱七八糟的原始数据。某次帮朋友处理一份学校满意度调查两千多份问卷导出成 Excel 后光清洗就花了大半天——选项里混着“非常满意 ”和“非常满意”多选题被拆成好几列开放题里有人填“无”“没意见”“。。。”。这次经历让我下定决心写一套 Python 脚本把「问卷数据统计」这件事从手动复制粘贴变成一条命令跑完。这个项目做的事很简单输入一份问卷导出的原始表格自动完成数据清洗、单选多选频率统计、量表题均值计算、交叉分析最后生成带图表的 Excel 报告和 Word 总结。适合经常做问卷调研的学生、产品经理、市场运营以及所有被问卷数据折磨过的人。项目不依赖问卷平台不管你用问卷星、腾讯问卷还是自己发的纸质问卷录入表只要导出的数据格式是行列规整的表格就能直接跑。2. 整体架构与设计思路为什么选 pandas 而不是写一堆 for 循环2.1 技术选型pandas 做数据清洗openpyxl 做报告输出matplotlib 画图项目基于 Python 3.8核心依赖只有三件套pandas 负责数据读取和处理matplotlib 做图表绘制openpyxl 负责把统计结果写回 Excel。这三个库是 Python 数据处理领域最成熟的组合pandas 的 DataFrame 天然适合问卷这种“一行一份问卷、一列一道题”的结构化数据。选择 pandas 而不是自己写 for 循环遍历单元格核心原因是问卷数据的处理逻辑虽然不复杂但操作步骤特别多——去重、替换、列拆分、透视、分组聚合这些操作在 pandas 里都有现成方法。比如问卷里常见的多选题导出后可能是“A,B,C”这种逗号分隔的文本也可能是“A|B|D”这种自定义分隔符用 pandas 的 str.split 加 explode 两步就能拆成一列标准格式手写循环要处理边界情况的地方就太多了。提示项目对输入数据有一个硬性要求——第一行必须是题目标题每一列对应一道题或一个选项。如果问卷平台导出的是多层表头比如“满意度-非常满意”“满意度-满意”这种分组表头需要先在 Excel 里手工合并成单层表头这是整个流程里唯一需要人工介入的步骤。2.2 处理链路一份问卷从原始数据到统计报告的完整流程项目的处理流程分成五个阶段从 main.py 入口开始按顺序执行数据读取用 pandas.read_excel 读取原始问卷数据同时读取一份配置文件 config.json里面记录了每道题的题型单选题/多选题/量表题/开放题和选项列表数据清洗处理缺失值、统一选项文本、去除重复答卷统计分析按题型分别计算频数、百分比、均值、标准差支持按某个背景题比如“您的年级是”做分组交叉统计图表生成针对每道题生成柱状图或饼图保存为 PNG 图片报告输出用 openpyxl 把所有统计结果写入 Excel每个工作表存放一个题型的统计结果图表以图片形式嵌入每个阶段都是独立的 Python 模块模块之间通过 DataFrame 传递数据。好处是调试时可以单独跑某个模块比如只跑清洗逻辑然后打印输出结果检查处理得对不对。我在设计时把清洗和统计严格分开没有混在一个函数里这样即使换了问卷平台、改了数据格式只需要动数据清洗模块统计分析逻辑完全不用碰。3. 目录结构与核心代码实现逐行拆解关键模块3.1 项目目录每个文件负责什么项目解压后的目录结构如下questionnaire_stats/ ├── main.py # 主入口编排整个处理流程 ├── config.json # 问卷配置题型、选项、分隔符 ├── requirements.txt # 依赖列表 ├── data/ │ └── raw_survey.xlsx # 原始问卷数据用户自己放 ├── src/ │ ├── __init__.py │ ├── cleaner.py # 数据清洗模块 │ ├── analyzer.py # 统计分析模块 │ ├── chart_generator.py # 图表生成模块 │ └── report_writer.py # Excel 报告输出模块 └── output/ └── stats_report.xlsx # 生成的统计报告main.py 是整个项目的调度中心逻辑很简单读取配置读取数据依次调用 cleaner清洗、analyzer分析、chart_generator画图、report_writer写报告。下面看 main.py 的核心代码# main.py import json import pandas as pd from src.cleaner import DataCleaner from src.analyzer import SurveyAnalyzer from src.chart_generator import ChartGenerator from src.report_writer import ReportWriter def main(): # 读取问卷配置config.json 里定义了每道题的题型和选项 with open(config.json, r, encodingutf-8) as f: config json.load(f) # 读取原始问卷数据这里默认 Excel 的第一个工作表 raw_df pd.read_excel(data/raw_survey.xlsx, sheet_name0) # 第一步清洗数据返回干净的 DataFrame cleaner DataCleaner(raw_df, config) clean_df cleaner.clean() # 第二步统计分析返回一个字典key 是题目标题value 是统计结果 analyzer SurveyAnalyzer(clean_df, config) stats_result analyzer.analyze_all() # 第三步生成图表返回图片文件路径列表 chart_gen ChartGenerator(stats_result, config) chart_paths chart_gen.generate_all() # 第四步写入 Excel 报告 writer ReportWriter(stats_result, chart_paths, config) writer.write_report(output/stats_report.xlsx) print(统计报告已生成: output/stats_report.xlsx) if __name__ __main__: main()逻辑说明main 函数就是一条流水线四步操作依次执行每一步的输入是上一步的输出。如果你只改了数据文件但配置没变跑一次全流程即可如果你改了某道题的题型只需要改 config.json不需要动代码。参数方面sheet_name0表示读取 Excel 的第一个工作表如果你的问卷数据在第二第三个表改这里就行。3.2 数据清洗模块统一选项文本、处理缺失值、去重数据清洗是整个项目里最“脏”的活也是最容易出 bug 的地方。cleaner.py 做了四件事删除完全重复的行、处理答题时间过短的问卷、统一选项文本表述、填充缺失值。核心代码# src/cleaner.py import pandas as pd import re class DataCleaner: def __init__(self, df: pd.DataFrame, config: dict): self.df df.copy() self.config config # 假设数据里有一列叫 提交时间实际按你的问卷列名改 self.time_col config.get(time_column, 提交时间) def clean(self) - pd.DataFrame: # 第一步去掉完全重复的行 self.df self.df.drop_duplicates() # 第二步过滤答题时间过短的问卷去掉小于 20 秒的 if self.time_col in self.df.columns: # 提交时间 一般是 datetime 格式这里按实际格式解析 self.df self.df.dropna(subset[self.time_col]) # 如果配置了最短答题秒数就过滤掉 min_seconds self.config.get(min_seconds, 20) if min_seconds 0: self.df self.remove_quick_submissions(min_seconds) # 第三步统一选项文本比如 非常满意 去掉空格非常满意。 去掉句号 for question, q_config in self.config[questions].items(): if q_config[type] in (single, multiple): self.df[question] self.df[question].apply( lambda x: self.normalize_option(x, q_config) ) return self.df def normalize_option(self, value, q_config): 把选项文本统一成配置里定义的标准写法 if pd.isna(value) or value : return 未作答 # 去掉首尾空格和全角空格 text str(value).strip().replace(\u3000, ) # 去掉常见噪音字符 text re.sub(r[。.!~\s], , text) # 如果清洗后文本在配置的标准选项列表里直接返回标准选项 # 否则返回原文本留到统计分析时当作其他 for opt in q_config[options]: if text opt or text in q_config.get(aliases, {}).get(opt, []): return opt return text逻辑说明normalize_option函数做的是把人工填写时的各种变体映射回标准选项。比如配置里标准选项是“非常满意”但有人填了“非常满意”或“非常满意 ”全角空格清洗时都会归一到“非常满意”。参数说明q_config[options]是 config.json 里定义的标准选项列表aliases是可选配置项用来定义同义词映射。如果你发现清洗后还有漏网之鱼比如有人填“相当满意”你可以把它加进 aliases 里映射到“非常满意”。3.3 统计分析模块单选频数、多选拆分、量表均值analyzer.py 是统计逻辑的核心它按题型分发到不同的处理方法。单选统计用 value_counts多选统计先拆分再计数量表题算均值和标准差。先看单选统计# src/analyzer.py import pandas as pd import numpy as np class SurveyAnalyzer: def __init__(self, df: pd.DataFrame, config: dict): self.df df self.config config self.result {} def analyze_all(self) - dict: for question, q_config in self.config[questions].items(): q_type q_config[type] if q_type single: self.result[question] self.analyze_single(question, q_config) elif q_type multiple: self.result[question] self.analyze_multiple(question, q_config) elif q_type scale: self.result[question] self.analyze_scale(question, q_config) elif q_type text: # 开放题只统计回答率和词频不画图 self.result[question] self.analyze_text(question) return self.result def analyze_single(self, question, q_config): 单选题统计每个选项的频数和百分比 # 清洗时已经统一了选项这里直接计数 counts self.df[question].value_counts(dropnaFalse) total counts.sum() # 计算百分比保留一位小数 percent (counts / total * 100).round(1) # 生成 DataFrame 方便写入 Excel result_df pd.DataFrame({ 选项: counts.index, 频数: counts.values, 百分比: percent.values }) # 按配置里选项的顺序排序而不是按频数排序 option_order q_config[options] [未作答, 其他] result_df[选项] pd.Categorical(result_df[选项], categoriesoption_order, orderedTrue) result_df result_df.sort_values(选项) return result_df def analyze_multiple(self, question, q_config): 多选题选项可能被逗号分隔先拆分再统计 # 从配置里读取多选分隔符默认是英文逗号 separator q_config.get(separator, ,) # 先把每行的文本按分隔符拆成列表再 explode 成一行一个选项 series self.df[question].dropna() exploded series.str.split(separator).explode() # 去掉拆分后可能出现的空字符串 exploded exploded[exploded.str.strip() ! ] # 去掉拆分后的选项首尾空格 exploded exploded.str.strip() counts exploded.value_counts() total counts.sum() percent (counts / total * 100).round(1) result_df pd.DataFrame({ 选项: counts.index, 被选次数: counts.values, 选择率: percent.values }) return result_df这个多选题是血泪经验换来的。问卷平台导出的多选题格式五花八门有逗号分隔的“A,B,C”有竖线分隔的“A|B|D”还有一个选项占一列的多列格式。上面代码用的是字符串拆分方式如果你的导出格式是“一个选项占一列”config.json 里需要加一个字段标记这种格式然后 analyzer 里要走另一条分支——把这几列转成行。多选统计的百分比口径也要注意分母是“选择该选项的人数”不是“总人数”代码里用 exploded 后的总行数做分母就是这个意思。量表题统计稍微复杂一点需要用户提前在 config.json 里把选项映射成数字def analyze_scale(self, question, q_config): 量表题把选项文本映射成 1-5 的分数计算均值 # 从配置里读取选项到分数的映射关系例如 {非常不同意: 1, 不同意: 2, 一般: 3, 同意: 4, 非常同意: 5} mapping q_config.get(score_map, {}) # 清洗时选项已经是标准文本直接映射成数字 scored self.df[question].map(mapping).dropna() if len(scored) 0: return pd.DataFrame({均值: [None], 标准差: [None], 样本数: [0]}) mean_val scored.mean().round(2) std_val scored.std().round(2) # 各分数档的频数分布也返回方便画分布图 dist scored.value_counts().sort_index() result_df pd.DataFrame({ 分数: dist.index, 人数: dist.values }) return { 均值: mean_val, 标准差: std_val, 样本数: len(scored), 分布: result_df }scale 题的返回结果是字典而不是 DataFrame因为后面报告生成时量表题需要单独组织格式——均值要放在醒目的位置分布要画直方图。这里建议 config 里 score_map 的 key 必须和选择题标准选项完全一致否则 map 不上的行会被 dropna 丢掉统计结果就会偏小这个坑后面专门说。3.4 报告生成模块把统计结果写进多工作表 Excelreport_writer.py 用 openpyxl 把分析结果写入 Excel每个题型一个工作表图表以图片形式嵌入。核心代码分两块一个是写频数统计表一个是嵌入图表。# src/report_writer.py import pandas as pd from openpyxl import Workbook from openpyxl.drawing.image import Image as ExcelImage from openpyxl.styles import Font, Alignment import os class ReportWriter: def __init__(self, stats_result: dict, chart_paths: list, config: dict): self.stats_result stats_result self.chart_paths chart_paths # 这个参数在 write_report 里使用 self.config config def write_report(self, output_path: str): wb Workbook() # 第一个工作表总览 ws_summary wb.active ws_summary.title 总览 summary_data self.build_summary() # 写入总览表头 ws_summary.append([题型, 题目标题, 样本数, 核心指标]) for row in summary_data: ws_summary.append(row) # 单选和多选各占一个工作表 for question, result in self.stats_result.items(): q_config self.config[questions][question] if q_config[type] in (single, multiple): sheet_name question[:28] # Excel 工作表名最长 31 字符留点余量 ws wb.create_sheet(titlesheet_name) if isinstance(result, pd.DataFrame): # 把 DataFrame 逐行写入工作表 for col_idx, col_name in enumerate(result.columns, start1): ws.cell(row1, columncol_idx, valuecol_name) for row_idx, row in result.iterrows(): for col_idx, value in enumerate(row, start1): ws.cell(rowrow_idx 2, columncol_idx, valuevalue) # 写入对应的图表 chart_path self.find_chart(question) if chart_path and os.path.exists(chart_path): img ExcelImage(chart_path) # 把图放在表格右侧避免覆盖数据 img.anchor fE{row_idx 2} ws.add_image(img) wb.save(output_path) def find_chart(self, question): 根据题目标题匹配生成的图表文件 for path in self.chart_paths: # 图表文件名包含题目标题的前 N 个字符用于匹配 if question[:8] in path: return path return None这段代码要特别注意一个隐蔽性问题Excel 工作表名不能超过 31 个字符而且不能包含\/?*[]这些字符。问卷题目往往很长直接拿题目标题建表必然报错所以我这里做了截断。更稳的做法是在 config.json 里给每道题配一个短标识比如q1、q2用短标识做工作表名题目标题作为单元格内容写进去。这个方法被很多人忽略等真正跑起来遇到IllegalCharacterError才知道锅在哪。4. 常见问题与避坑指南这些坑我都踩过4.1 编码问题Excel 文件里读出来的中文全是乱码现象pandas 读取 Excel 文件后打印 DataFrame 发现所有中文字段都是乱码甚至直接报错UnicodeDecodeError。原因Excel 文件内部有自己的一套编码机制pandas 读 Excel 用的是 openpyxl 引擎本身不会出现读取乱码问题。但如果你的数据源是从 CSV 转过来的或者问卷平台导出的是 CSV 而不是 xlsx再用pd.read_csv(data.csv)读就涉及到编码问题。最常见的坑是 Windows 下导出的 CSV 用 GBK 编码而 pandas 默认用 UTF-8 读取直接报错。解决用pd.read_csv(data.csv, encodinggbk)读 GBK 编码的 CSV如果不确定编码类型可以用chardet库自动检测文件编码再读取。强烈建议统一用 Excel 格式xlsx传递数据不要手动另存为 CSVxlsx 在 pandas 里读取是零编码负担的。4.2 多选题列拆分后出现大量空白选项现象多选统计结果里“选项”列莫名其妙多了一行空字符串数据量还很大。原因原始数据里多选题列的分隔符不是统一风格比如有的是“A, B”逗号加空格有的选项后面带了换行符\n。str.split(,)拆完之后explode会保留空字符串如果原始数据是“A,,B”这种连续两个逗号就会产生一个空选项。解决explode之后加一道过滤把空字符串和纯空格的行删掉。上面的代码已经处理了这个问题但如果你自己改过逻辑记住一定要在str.split()之后.str.strip()再去掉空值。另外一个更隐蔽的坑是问卷平台导出的多选分隔符是“|”而不是逗号config.json 里的 separator 忘记改就会把整个选项当成一个字符串统计结果全错。4.3 量表题映射失败导致样本量骤减现象量表题统计结果里的样本数比总问卷数少了三分之一均值明显失真。原因score_map里的 key 和清洗后的选项文本对不上。比如原始数据里有人填了“非常同意”全角空格清洗模块的normalize_option依赖 config 里的aliases来做同义词映射但如果 aliases 里没有配置这个变体清洗后的文本就不是标准选项score_map自然映射不上dropna()就会把这些行丢掉。解决第一步先检查清洗后的选项分布clean_df[question].value_counts()看有没有“漏网之鱼”。如果有把这些非标准文本加进 config 的 aliases 映射里。我一般会先对所有量表题跑一遍value_counts确认选项种类不超过 5 种再做映射这样能早发现问题。4.4 Excel 工作表名包含非法字符导致程序崩溃现象wb.create_sheet(titlequestion)直接抛异常程序停在报告生成这一步。原因Excel 工作表名不允许出现\ / ? * [ ] :这些字符而问卷题目里经常有逗号、冒号、括号比如“您对食堂服务的满意度请打分”直接作为工作表名就触发了非法字符异常。解决给每道题在 config 里加一个短 ID用短 ID 作为工作表名。我在 3.4 节留了这个口子如果不想改 config至少要做一层清洗——正则去掉非法字符再截断到 28 个字符以内。从那次之后我每次写 Excel 报告都会先检查一遍工作表名长度和合法字符这个习惯再没翻过车。5. 从跑通到改造如何把脚本变成你自己可复用的工具5.1 改造方向一接入更多问卷平台的导出格式问卷数据最麻烦的地方是不同平台导出格式差异极大。问卷星导出的是“一题一列”的宽表腾讯问卷的多选题可能是“多个选项各占一列”而问卷网可能把同一道矩阵题的多个子题放在连续列里。要兼容这些格式核心思路是扩展 cleaner.py 的读取逻辑用一个reader子模块针对不同平台写不同的读表函数但统一输出同一个标准 DataFrame 格式下游的 analyzer 和 report_writer 不需要改动。具体做法是给 config.json 加一个platform字段main.py 根据这个字段选择不同的读取函数。我自己改造时二开了一个公众号后台的投票数据接口直接把 JSON 转成 DataFrame走到了同一套统计流程里。5.2 改造方向二增加交叉分析和显著性检验问卷调研里经常需要按背景题分组看差异比如“不同年级对食堂满意度的评价是否有差异”。可以在 analyzer.py 里增加一个analyze_cross方法接受两个参数分组列背景题和分析列目标题内部用pd.crosstab生成交叉表再用 scipy.stats 做卡方检验。虽然项目本身不带 scipy 依赖但这是加一个import scipy就能实现的功能。注意交叉表的百分比口径单选交叉统计应该用行百分比每组的内部比例不要用列百分比不然不同组的样本量不一致时很容易得出错误结论。5.3 改造方向三报告格式从 Excel 升级到 HTML 或 PDFExcel 报告适合数据整理但给导师或客户汇报时图文并茂的 PDF 更专业。改造路线是report_writer.py保持统计结果的部分不动新增一个html_renderer.py用 Jinja2 模板把 stats_result 渲染成 HTML 报告再用 weasyprint 转成 PDF。Jinja2 模板里可以用 for 循环遍历每道题的统计表图表图片用相对路径引用。这个改造只动输出层不动清洗和分析层的任何代码收益很高——HTML 报告可以做目录、加封面、加解释性文字比 Excel 灵活得多。5.4 配置规范化把 config.json 变成你的“问卷 schema”如果要把这套工具用在不同的调研项目上每次改 config.json 是最容易出错的环节。我后来做了一套配置自动生成工具把问卷平台导出的题目列表读进来自动生成一个 config 模板人工只需要标出哪些是单选、哪些是多选、哪些是量表题。这个思路的核心价值是把“写配置”变成“校验配置”大幅减少踩坑概率。每次跑新项目之前先跑一遍配置检查脚本它会对比 config 里的题目标题和数据文件里的列名列名对不上直接报错这样就不会出现跑完才发现某道题被静默跳过的惨剧。从开始写这个项目到现在我看问卷数据的心态变了以前打开 Excel 看到两千行不知道怎么下手现在直接扔进脚本里一杯咖啡的功夫报告就出来了。如果你也有被问卷数据折磨的经历不妨从这里找找灵感——哪怕只是跑通一条单选题的统计也算迈过了最难的坎。希望帮到你。本文还有配套的精品资源点击获取