☰
Python问卷数据统计全流程:清洗、口径与交叉分析实战
2026/10/12 16:00:15 网站建设 项目流程

简介:面向使用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", encoding="utf-8-sig") # 情况二:Excel 文件,需要指定 engine df = pd.read_excel("survey.xlsx", sheet_name="Sheet1", dtype=str) # 情况三:JSON 文件,通常是记录列表 import json with open("survey.json", "r", encoding="utf-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 时强制 dtype=str,是为了让题号列、时间列不被 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(columns=rename_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"], how="any")

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"], margins=True, margins_name="合计", ) print(cross_table)

margins=True 会额外生成一行、一列合计,方便检查每一行有效样本量。看到某个年级的合计明显低于其他年级,就要去回看原始数据的缺失情况。很多人上来就先做百分比交叉,但建议先看频数交叉,数字异常一眼能看出,百分比表现象会骗人。

把表格转成比例时,关键在 normalize 的方向。normalize="index" 是行归一化,每一行都变成百分比,做“同组客户分布差异”合适。normalize="columns" 按列归一化,适合看“某个满意度级别里哪个年级占比高”,这两种故事完全是两个方向。

cross_pct = pd.crosstab( df["grade"], df["satisfaction"], normalize="index", margins=True, ) * 100 print(cross_pct.round(1))

normalize="index" 算出的每一行比例之和是 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", engine="openpyxl") 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, start=1): 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.23E+15,后续文字识别完全没法用。

原因:Excel 对超过 11 位的纯数字列自动转科学计数,这是 Excel 的显示规则,不是 pandas 的问题。

解决:写入前把这些列用 astype(str) 转换,再写入。已经写坏的文件无法靠设置列宽回车修复,需要重新跑脚本导出。

5.4 中文列名在 read_csv 后显示为乱码

现象:df.head() 打印的列名是一堆“鏂囧瓧”,但数据本身正常。

原因:原文件编码是 GBK,读取时没有指定 encoding,pandas 默认用了 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, encoding="utf-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"], how="any") # 阶段二:单选统计(有效样本量做分母) single_cols = ["grade", "satisfaction"] single_results = {} for col in single_cols: single_results[col] = df[col].value_counts(normalize=True) * 100 # 阶段三:多选统计,支持长表和短表两种形态 multi_results = {} for col in multi_columns: if df[col].dtype == "object" and df[col].str.contains(",", na=False).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_name=name[:31]) for name, s in multi_results.items(): s.to_frame("频次").to_excel(writer, sheet_name=name[:31]) survey_report("survey.csv", ["multi_choice"], {"师资": ["老师"]}, "result.xlsx")

代码里唯一需要留意的是列名作为 sheet_name 时不能超过 31 个字符,否则 openpyxl 会报错,所以用切片截断。多选列判断用 str.contains 一行就够了,不用专门写循环判断格式。

6.2 自定义阈值和文字识别的边界

封装后的函数已经能应付常规需求,但真实数据往往有两个变化。第一个是选项数量差异较大,有的多选题有 8 个选项,有的只有 3 个,这时瓶颈不在代码而在归类依赖关键词映射。第二个是开发题的语义判断边界,简单任何() 匹配会误伤,比如“老师”关键词会把“老师上课时间安排不太合理”归到师资,这其实也合理,但“课程太挤导致没时间请教老师”这句话同时命中课程和师资,函数只能返回先匹配到的类。

所以我习惯在封装之外保留一个复核步骤:跑完函数后手动把归类到“其他”的文本扫一遍,如果“其他”占比超过三成,说明关键词清单不够细。

从那次因为漏看多选空格导致统计出错之后,每次交付问卷统计结果前,我强制自己走一遍三件事:用 df[col].nunique() 校验每个离散字段的取值数量。检查题名里是否有异常值。Excel 导出后抽查某一个年纪列做人工计数对照。这个习惯帮我挡掉了不少返工。希望这套流程也能让你的问卷数据少走点弯路,落地更快。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询