简介面向使用Python开展调查问卷数据统计的开发者这套学习资料尤其适合有课程设计、毕业设计或市场调研分析需求的读者。内容围绕问卷数据的读取清洗、统计汇总与可视化展示展开覆盖从原始数据到结论呈现的完整处理链路配套讲解思路和实践脚本可直接迁移到实际项目中使用。整包共129个文件以PPT演示文稿、Markdown笔记、Word文档、PDF参考手册和Python脚本为主兼有Excel表格与示例图片既方便按章节学习也能对照运行压缩包整体约185.63MB。其中还包含ISO质量体系标准、测试用例编写规范及软件设计/开发文档模板等辅件可用于规范问卷统计项目的文档撰写。目前已有208人学习下载对希望减少手工统计、快速产出图表报表的Python用户来说是一份有参考价值的资料包。1. 问卷数据统计这条路比想象中长在清洗和口径上拿到一份问卷导出数据第一反应往往是“用求和函数算百分比不就行了”。真跑起来才发现官方导出格式千奇百怪、多选字段格式不统一、小数点跑位、编码全乱这些统计之外的问题占掉七八成时间。这套用 Python 实现的问卷数据统计资源核心就是把“读入——清洗——统计——交叉分析——出报告”串成一条自动化流水线适合需要定期处理调研数据的人比如要做用户反馈分析的产品同学、做教学评估的教务老师还有帮导师跑问卷的在校生。上手门槛不高pandas 基础语法过关就能跟着复现但这里面的统计口径和清洗规则才是真正值得抄作业的部分。2. 读入与体检先把原始表变成能算数的数据集统计的第一步不是算而是把数据搬进 pandas 并且看清楚每一列在说什么。问卷导出格式多种多样直接 read_csv 或 read_excel 往往只是开始真正的坑是字段类型和缺失值还没人告诉你。2.1 三种常见导出格式读法都不一样问卷平台最常见的是 CSV、XLSX 和 JSON 三种格式读取逻辑差别不小。import pandas as pd # 情况一CSV 文件注意编码参数 df pd.read_csv(survey.csv, encodingutf-8-sig) # 情况二Excel 文件需要指定 engine df pd.read_excel(survey.xlsx, sheet_nameSheet1, dtypestr) # 情况三JSON 文件通常是记录列表 import json with open(survey.json, r, encodingutf-8) as f: raw json.load(f) df pd.DataFrame(raw) print(df.shape, df.columns.tolist())读 CSV 时最常见的问题是 GBK 编码乱码utf-8-sig 能处理带 BOM 的 Excel 导出文件。读 Excel 时强制 dtypestr是为了让题号列、时间列不被 pandas 自动推断成数字或日期这给后面清洗留了余地。JSON 一般来自网络爬虫或前后端对接raw 是字典列表DataFrame 构造完顺手打印 shape 和列名先确认数据真的进来了。读完之后我习惯做一次“肉眼巡检”df.head() 看前几行、df.info() 看各列类型和空值数量、df.nunique() 看离散值数量。这一步不写代码也能做但一旦字段有几十个手工效率太低我一般直接跑一个小循环。for col in df.columns: missing_rate df[col].isna().mean() unique_count df[col].nunique() print(f{col}: 缺失率 {missing_rate:.2%}, 唯一值 {unique_count})缺失率和唯一值同步输出能判断哪些列适合做交叉维度哪些列是纯粹的自由填写题。比如某地区字段缺失率超过 30%那拿它做人群分组的可信度就很低宁可放弃。唯一值接近行数的列一般是录音或验证码几乎不可能用于统计应直接进入过滤名单。2.2 字段体检先看缺失率、唯一值再动手统计体检查出来的问题一般有三种列名不规范、值格式混乱、有大量缺失。列名不规范的常见形态是“题目文字”占整列名处理方式是重命名成一个简短 ID 映射方便后续写代码。rename_map { 您所在的年级: grade, 您对课程内容的满意程度: satisfaction, 您的建议选填: feedback, } df df.rename(columnsrename_map) df.columns [c.strip() for c in df.columns] # 顺手去掉列名两侧空格字段体检表里我常看的几个指标整理如下指标怎么计算看到什么需要警惕缺失率isnull().mean()超过 30% 的列慎做交叉分析唯一值nunique()和样本量一致的列基本是无效 ID类型dtypesobject 列可能混入数字和文字样本量shape[0]清洗后行数减半要回头查过滤条件有些问卷平台导出的“未作答”不是空单元格而是字符串“-3”“-99”之类编码值。这种情况 isna() 检测不出来需要手工指定值列表。missing_flag [-3, -99, N/A, NA] df df.replace(missing_flag, pd.NA) df df.dropna(subset[grade, satisfaction], howany)replace 把各种缺失符号统一换成 pandas 的 NA再根据分析需求做 dropna。subset 里的列是分析核心维度那些非必须的开放题列不建议删行否则自由文本内容的样本量会被严重压掉。这一阶段的产出是一张字段口径表把每列改名规则、替代值规则、缺失规则记清楚后面做复现或交接时就省去了重看原始数据的麻烦。3. 统计口径单选题、多选题、开放题分开算问卷统计绝对不是“统一 value_counts”题目类型不同分母和聚合逻辑两样。单选题算人群占比多选题要计算选项出现频次和选择人数占比开放题则要先做文本归类再进入表格。很多分析报告比例加起来超过 100%就是因为没区分好这几种口径。3.1 单选题用 value_counts但注意计数基数单选题的原始列里一个样本只有一个选项值。统计时最常见错误是直接用 value_counts 除以行数忽略了“未作答”样本。satisfaction_counts df[satisfaction].value_counts() valid_total df[satisfaction].notna().sum() satisfaction_ratio satisfaction_counts / valid_total print(satisfaction_ratio.round(4) * 100)value_counts 默认会丢弃 NaN分母要用该列的有效样本量 valid_total。反馈报告里如果写“满意度 78%”这个 78% 的基数是谁要想清楚。有些场景需要的是“占全体受访者比例”比如“课程满意度为 78%”的全体受众视角这种情况分母用 len(df)两种算法结论不同务必在报告里标注口径。多级分值类的单选题建议保留原始分值而是转换成标签例如把 1-5 分映射“很不满意”到“很满意”。这样展示表格时不会出现一列纯数字看报告的人也不会把 4.5 分误当成某种神秘指数。3.2 多选题和开放题拆选项、归一化、再汇总多选题在导出文件里有好几种存储形态。第一种是一列文本逗号分隔例如“选项A,选项B,选项C”。第二种是一列包含列表的 JSON 字符串。第三种是每选项一列选中为 1 或 0。三种形态都要先统一成“一行一个选项”的长表结构才能做汇总。df_multi df[[grade, multi_choice]].copy() # multi_choice 是 A,B,C 形式 df_multi[multi_choice] df_multi[multi_choice].str.split(,) df_long df_multi.explode(multi_choice) df_long[multi_choice] df_long[multi_choice].str.strip() option_counts df_long[multi_choice].value_counts() print(option_counts)explode 是处理多选统计的核心操作它把一个包含列表的单元格拆成多行其他字段自动复制。str.strip() 处理选项后面可能带空格的情况这一步不做后面归并时“选项B”和“选项B ”会被算成两个不同选项。做完 explode 后的总行数明显大于样本量这是正常的但要注意最终价值_counts 是“选项频次”不是“选择人数”。报告里要呈现“选择人数占比”必须用去重后的样本量做分母。另一种多选存储形态是每选项一列 0/1统计口径简单一些不需要 explode但交叉分析时太分散。我一般先转成长表再做各种分组。开放题统计最容易被忽略。开放题文本千差万别直接做词频统计意义不大我通常是按业务关注点做一个关键词映射归类。category_keywords { 师资: [老师, 教师, 授课, 讲课], 课程: [课, 内容, 大纲, 难度], 环境: [教室, 设备, 软件, 网络], } def classify_feedback(text): text str(text) for category, kws in category_keywords.items(): if any(kw in text for kw in kws): return category return 其他 df[feedback_category] df[feedback].apply(classify_feedback) print(df[feedback_category].value_counts())关键词归类法不依赖模型适合文本量不大、主要想快速分堆的场景。要注意一个反馈可能同时提到“老师”和“课程”这段代码只归入第一个命中的类别想要多标签统计就得把逻辑改成返回列表再 explode。4. 交叉分析用 crosstab 找出不同人群的差异统计完单题分布下一步是看“不同年级对满意度的影响”这类问题。交叉表就是二维频数表行列分别代表两个字段。用 pandas.crosstab 能在一两行内完成但要注意归一化参数和缺失值的处理方式。4.1 交叉表的构建与装配构建交叉表的最小代码是 pd.crosstab(df[grade], df[satisfaction])但这里有两个陷阱。第一个陷阱是默认只统计两个字段同时非空的样本空值组合直接消失导致行和加起来小于样本量。第二个陷阱是百分比方向margin 参数控制是否出现总行列。cross_table pd.crosstab( df[grade], df[satisfaction], marginsTrue, margins_name合计, ) print(cross_table)marginsTrue 会额外生成一行、一列合计方便检查每一行有效样本量。看到某个年级的合计明显低于其他年级就要去回看原始数据的缺失情况。很多人上来就先做百分比交叉但建议先看频数交叉数字异常一眼能看出百分比表现象会骗人。把表格转成比例时关键在 normalize 的方向。normalizeindex 是行归一化每一行都变成百分比做“同组客户分布差异”合适。normalizecolumns 按列归一化适合看“某个满意度级别里哪个年级占比高”这两种故事完全是两个方向。cross_pct pd.crosstab( df[grade], df[satisfaction], normalizeindex, marginsTrue, ) * 100 print(cross_pct.round(1))normalizeindex 算出的每一行比例之和是 100%合计行则会因为是多行归并、分母不同而超过 100%。写报告时不要把合计行百分比当作真实分布解读我一般打印时不带 margins只做单题分布时才带。多选字段做分组交叉时需要把已经 explode 的长表直接喂给 crosstab同时要去掉重复的行。一个用户可能同时选了 B 和 C它产生的多行在“选 B 的组”和“选 C 的组”都是合法贡献但若只看“人”的水平某个用户不能因为选两个选项就被算两次。df_long_dedup df_long.drop_duplicates(subset[grade, multi_choice]) cross_multi pd.crosstab(df_long_dedup[grade], df_long_dedup[multi_choice])4.2 结果输出到 Excel 的几个参数统计完成后人工复制表格再粘贴到 Word 报告里是最烦的环节。我一般直接把所有结果写进一个 Excel 工作簿多个 sheet 分别放单选、多选、交叉表和开放题归类。from openpyxl.utils import get_column_letter with pd.ExcelWriter(survey_report.xlsx, engineopenpyxl) as writer: satisfaction_ratio.to_frame(占比).to_excel(writer, sheet_name满意度) option_counts.to_frame(频次).to_excel(writer, sheet_name多选) cross_pct.to_excel(writer, sheet_name年级交叉) df[feedback_category].value_counts().to_frame(数量).to_excel(writer, sheet_name开放题归类)to_excel 默认自动调整的列宽并不美观中文字段经常被截断。可以在写完每个 sheet 后遍历列根据该列最大内容宽度设置列宽但 openpyxl 的 width 计算是一个优化点。另外数据量特别大时Excel 会把长数字列默认为科学计数法解决办法是在写入前把列转字符串。for sheet_name in writer.sheets: ws writer.sheets[sheet_name] for i, col in enumerate(ws.columns, start1): max_len max(len(str(c.value)) for c in col) ws.column_dimensions[get_column_letter(i)].width max(min(max_len 4, 60), 10)提示引擎用 openpyxl 时同一 writer 写多个 sheet 是连续操作写完一个再写下一个别在中途 save。5. 避坑记录五个真实翻车现场与处理方式问卷数据统计的坑往往很隐蔽看起来代码没报错但结果逻辑不对。下面五个场景经常在真实项目中出现每一条都值得在正式跑数据前检查一遍。5.1 百分比加起来不等于 100%现象表格里各项占比加总后是 101% 或 99%四舍五入的误差累积导致不齐整在正式报告里格外显眼。原因比例值在计算后被 round(2) 单独取整每一行都各自四舍五入误差没有做归一化修正。解决这一般不影响结论但报告审查时容易被挑错。处理方式是保留至少 4 位小数做底层计算展示层再四舍五入让合计误差控制在 0.1% 内后在正文写明“因四舍五入各项之和可能不等于 100%”。5.2 多选题里同一选项出现两个相似写法现象value_counts 结果里出现了“选项 B”和“选项B”两个条目频次还不低。原因原始数据空格和全角字符不统一str.split 后没有 trim或者空格是全角空格无法用 str.strip() 去除。解决将 pd.Series.str.strip() 换成自定义清理函数用正则处理 Unicode 空白字符。全角空格的 Unicode 码位是 \u3000要做统一替换。def clean_opt(x): return re.sub(r[\s\u3000], , str(x)) df[multi_choice] df[multi_choice].apply(clean_opt)5.3 Excel 中的长 ID 或填空题编号变成科学计数法现象导出 Excel 后时间戳或学号显示成 1.23E15后续文字识别完全没法用。原因Excel 对超过 11 位的纯数字列自动转科学计数这是 Excel 的显示规则不是 pandas 的问题。解决写入前把这些列用 astype(str) 转换再写入。已经写坏的文件无法靠设置列宽回车修复需要重新跑脚本导出。5.4 中文列名在 read_csv 后显示为乱码现象df.head() 打印的列名是一堆“鏂囧瓧”但数据本身正常。原因原文件编码是 GBK读取时没有指定 encodingpandas 默认用了 UTF-8。解决读取前先试编码。常见做法是先用 gbk 读失败再用 utf-8-sig也可以先用二进制打开文件读取前两行做编码探测。5.5 清洗后的样本量对不上现象原始数据有 1200 行清洗后只有 700 行且没有明显报错。原因dropna 的 how 参数误设为 any导致任何有缺失的字段都删掉了该行开放题空白的样本全被误删样本量大幅缩水。解决把开放题列从一开始就从 dropna 的 subset 里移除。“用户没填写建议”不代表用户不是有效样本只有核心题目缺失才删除整行。另外每执行一次过滤就要打印一次 shape 对比等到最后再查是哪一步出错排查成本会高很多。6. 把脚本做成可复用工具一个函数跑完整个统计表格处理脚本如果在每个新项目里都要重写那就浪费了这次拆解的意义。最后这一步把前面所有步骤封装成一个函数数据路径和映射表作为参数传入新数据只要跑一行就能输出全套结果。6.1 核心函数设计函数的功能是接收一个 CSV 路径做清洗、单选统计、多选统计、交叉表和开放题归类输出一个 Excel 文件。整个流程按“读入→体检→清洗→分题统计→写出”五段式组织。def survey_report(path, multi_columns, open_keywords, output_path): df pd.read_csv(path, encodingutf-8-sig) # 阶段一字段重命名与缺失值统一 df.columns [c.strip() for c in df.columns] df df.replace([-3, -99, N/A], pd.NA) df df.dropna(subset[grade, satisfaction], howany) # 阶段二单选统计有效样本量做分母 single_cols [grade, satisfaction] single_results {} for col in single_cols: single_results[col] df[col].value_counts(normalizeTrue) * 100 # 阶段三多选统计支持长表和短表两种形态 multi_results {} for col in multi_columns: if df[col].dtype object and df[col].str.contains(,, naFalse).any(): long_df df[col].str.split(,).explode().str.strip() multi_results[col] long_df.value_counts() # 阶段四开放题关键词归类 for category, kws in open_keywords.items(): df[category] df[feedback].apply( lambda t: category if any(k in str(t) for k in kws) else 其他 ) with pd.ExcelWriter(output_path) as writer: for name, s in single_results.items(): s.to_frame(比例).to_excel(writer, sheet_namename[:31]) for name, s in multi_results.items(): s.to_frame(频次).to_excel(writer, sheet_namename[:31]) survey_report(survey.csv, [multi_choice], {师资: [老师]}, result.xlsx)代码里唯一需要留意的是列名作为 sheet_name 时不能超过 31 个字符否则 openpyxl 会报错所以用切片截断。多选列判断用 str.contains 一行就够了不用专门写循环判断格式。6.2 自定义阈值和文字识别的边界封装后的函数已经能应付常规需求但真实数据往往有两个变化。第一个是选项数量差异较大有的多选题有 8 个选项有的只有 3 个这时瓶颈不在代码而在归类依赖关键词映射。第二个是开发题的语义判断边界简单任何() 匹配会误伤比如“老师”关键词会把“老师上课时间安排不太合理”归到师资这其实也合理但“课程太挤导致没时间请教老师”这句话同时命中课程和师资函数只能返回先匹配到的类。所以我习惯在封装之外保留一个复核步骤跑完函数后手动把归类到“其他”的文本扫一遍如果“其他”占比超过三成说明关键词清单不够细。从那次因为漏看多选空格导致统计出错之后每次交付问卷统计结果前我强制自己走一遍三件事用 df[col].nunique() 校验每个离散字段的取值数量。检查题名里是否有异常值。Excel 导出后抽查某一个年纪列做人工计数对照。这个习惯帮我挡掉了不少返工。希望这套流程也能让你的问卷数据少走点弯路落地更快。本文还有配套的精品资源点击获取