简介: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', encoding='utf-8') as f: config = json.load(f) # 读取原始问卷数据,这里默认 Excel 的第一个工作表 raw_df = pd.read_excel('data/raw_survey.xlsx', sheet_name=0) # 第一步:清洗数据,返回干净的 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_name=0表示读取 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(dropna=False) 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['选项'], categories=option_order, ordered=True) 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 报告生成模块:把统计结果写进多工作表 Excel
report_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(title=sheet_name) if isinstance(result, pd.DataFrame): # 把 DataFrame 逐行写入工作表 for col_idx, col_name in enumerate(result.columns, start=1): ws.cell(row=1, column=col_idx, value=col_name) for row_idx, row in result.iterrows(): for col_idx, value in enumerate(row, start=1): ws.cell(row=row_idx + 2, column=col_idx, value=value) # 写入对应的图表 chart_path = self.find_chart(question) if chart_path and os.path.exists(chart_path): img = ExcelImage(chart_path) # 把图放在表格右侧,避免覆盖数据 img.anchor = f'E{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', encoding='gbk')读 GBK 编码的 CSV;如果不确定编码类型,可以用chardet库自动检测文件编码再读取。强烈建议:统一用 Excel 格式(xlsx)传递数据,不要手动另存为 CSV,xlsx 在 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(title=question)直接抛异常,程序停在报告生成这一步。
原因: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 或 PDF
Excel 报告适合数据整理,但给导师或客户汇报时,图文并茂的 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 看到两千行不知道怎么下手,现在直接扔进脚本里,一杯咖啡的功夫报告就出来了。如果你也有被问卷数据折磨的经历,不妨从这里找找灵感——哪怕只是跑通一条单选题的统计,也算迈过了最难的坎。希望帮到你。
本文还有配套的精品资源,点击获取