简介:这是一份2024年发布的Python实现调查问卷数据统计资料包,面向数据分析初学者、产品运营及市场调研人员,也可用于课程设计或内部培训。内容围绕问卷数据的清洗、分类汇总、频次分析与可视化展示等常见环节展开,覆盖单选题、多选题及量表题的处理思路,帮助用户掌握从原始答卷到统计结果的完整流程。压缩包共129个文件,大小约185.63MB,主要包含pptx演示文稿、doc/docx文档、md笔记、pdf说明和xlsx表格,并附有Python脚本与示例图片,便于对照学习、改造复用。已有208人学习下载,整体内容兼具教学讲解与实操代码。资料中还收录了软件工程标准、测试用例编写规范、软件设计规格说明书等配套文档,适合在规范流程下推进数据统计任务,目录分类较清晰,基本覆盖项目准备、方案设计到结果输出的关键资料。
1. Python调查问卷数据统计:洗净数据、算清多选、交付一份能用的报告
2024年了,收到的问卷导出表,最常见的依然是几百行Excel里塞着“非常满意|满意|一般”这类文本,以及平台把多选题拆成五六个布尔列的“中国特色”格式。手动用透视表做统计,一次两次还行,题目一多、样本一涨,人就成了大型机器。Python做调查问卷数据统计,核心就三件事:把脏数据洗干净、按单选和多选分别算频次、把结果变成图和表。这篇文章不聊框架,也不上复杂模型,就讲怎么用pandas把统计跑出来,踩过哪些雷也一并交代。适合手里攥着问卷导出表、要交统计结果但不想重复劳动的人。
2. 先拿到能统计的数据:问卷导出表的清洗与规范化
2.1 为什么先统一列名和值域,再谈统计
多数问卷平台导出的Excel,表头往往带着题号,比如“Q1.您的性别”“Q4_2.您选择以下哪些渠道”。直接拿这些列名写统计代码,十几个题还行,三十个题一过就分不清谁是谁。我一般先在脚本里打印列名,人工把需要的题改成短名字,再往下做。不要嫌这一步麻烦,统计翻车,几乎都从列名和值域不统一开始。
import pandas as pd # 读取问卷导出表,强制按字符串读入,避免手机号、日期被自动转换 df = pd.read_excel("survey_export.xlsx", header=0, dtype=str) print("shape:", df.shape) print("columns:") for idx, col in enumerate(df.columns): print(idx, col)这里header=0表示第一个非空行作为列名。如果平台在表头前加了两三行说明,就要把header参数改成对应的行号。dtype=str是常用的保险写法,读进来后所有列都是字符串,之后再按业务逻辑转类型,这能防止选项编码、手机号、日期被pandas自作主张改成float或datetime。
拿到列名后,我习惯建一个映射字典,把别扭的题目名改成语义明确的短名字:
rename_map = { "Q1.您的性别": "gender", "Q2.您的年龄段": "age_group", "Q3.您的满意度": "satisfaction", "Q4.您常使用的功能(多选)": "features", "Q5.您提交的建议": "suggestion", } df = df.rename(columns=rename_map) print(df[["gender", "age_group", "satisfaction"]].head())如果原始列名已经混乱到没有规律,临时用位置索引比如df.columns[2]也能跑,但不建议长期依赖。平台更新一次导出模板,列顺序一变,你的脚本就得跟着重改。统一列名的过程,本质上是把“平台导出的结构”翻译成“统计脚本能理解的结构”。
2.2 缺失值、乱码值和“未选”怎么处理
问卷平台对未答题目的导出记号很不统一:有的是空单元格,有的塞一个空格,有的写“(空)”,有的用“未填”。如果直接统计,这些符号都会变成独立的“选项”,饼图里会多出一堆莫名其妙的分类。
missing_tokens = ["", " ", "(空)", "未填", "NULL", "NA"] # replace会把列表里的每一个值都替换为pd.NA df = df.replace(missing_tokens, pd.NA) # 查看每列的缺失占比,用来决定哪些题适合继续分析 missing = df.isna().mean().sort_values(ascending=False) print(missing)pd.NA是pandas推荐的缺失标记,后面用dropna=False可以把缺失单独列出来看。不要一上来就dropna整行,问卷有人跳过一两题很常见,整行删除会白丢样本。平台偶尔会把“其他”选项的文本写到“其他_文本”这类附加列里,清洗时要把这种附加列和主选项分开,不要混进单选统计。
除了缺失,还要防全角空格和不可见字符。中文问卷里经常混入全角空格,导致“非常满意”和“非常满意 ”被当成两个选项。常见做法是统一去掉首尾空格:
for col in ["gender", "age_group", "satisfaction"]: df[col] = df[col].str.strip()str.strip()删掉的是首尾空白,不会动选项内部的空格。处理完后再跑一次value_counts(dropna=False),看一眼还剩下哪些奇怪的值。这一步是最值的投资,能省掉后面无数个图表排错的夜晚。
2.3 多选题的两种存储方式与标准化
多选题是问卷统计的第一个分水岭。有的平台把每个选项单独做成一列,值填0/1;也有的平台把勾选项用竖线拼在一列里,比如“功能A|功能C”。两种格式都得标准化,统计口径才稳。
对于“一列多值”的格式,我通常写一个小函数拆成列表:
def parse_multi(row_value): """把竖线分隔的多选内容拆成列表,空值返回空列表""" if pd.isna(row_value): return [] # 去掉头尾空格后再按竖线拆 return [x.strip() for x in str(row_value).split("|") if x.strip()] df["features_list"] = df["features"].apply(parse_multi) print(df["features_list"].head())split("|")之后,每个元素可能有空格,所以接一个strip()。if x.strip()把空字符串过滤掉,避免出现逗号或空选项。这里返回的是列表对象,不要试图用apply直接生成多列,后续统计时用Counter展开更方便。
对于“多列,每列一个选项”的格式,先确认每列的值是不是只有0和1。平台偶尔会用“是/否”或者“True/False”,要先统一:
bool_cols = [c for c in df.columns if c.startswith("features_")] for c in bool_cols: df[c] = df[c].replace({"是": 1, "否": 0, "True": 1, "False": 0}) df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0) print(df[bool_cols].head())errors="coerce"会把没法解析成数字的文本变成NaN,再用fillna(0)补成0。这样做的好处是,异常值不会悄悄混进统计结果。如果你想知道到底有多少异常值,可以把coerce前后的列做一下差异对比,比如df[c].isna().sum()。等到确认值域只含0和1,再继续做交叉分析。
3. 频次统计与交叉分析:单选题、多选题和分组对比的参数选择
3.1 单选题频次统计:value_counts排序会打乱量表顺序
单选题统计本身不复杂,但有两个坑。第一,value_counts默认按频次降序,“非常满意”这种高频选项会排在最前面,报告里想按量表顺序展示反而乱。第二,dropna参数默认是False,缺失值会被静默丢掉,你根本不知道有多少人跳过这题。
col = "satisfaction" order = ["非常不满意", "不满意", "一般", "满意", "非常满意"] freq = df[col].value_counts(dropna=False) pct = df[col].value_counts(normalize=True, dropna=False) * 100 stat = pd.DataFrame({"人数": freq, "占比%": pct.round(2)}) # 缺失值在占比里已经出现,但需要单独标记 stat = stat.reindex(order).fillna(0) stat.loc["(缺失)"] = int(df[col].isna().sum()) print(stat)value_counts(normalize=True)返回的是比例,乘100后变成百分比。dropna=False让NaN也参与计数,否则你就看不到被跳过的样本量。这里最容易写错的是normalize=True会忽略dropna=False的交互:当dropna=False时NaN会计入总数,所以占比是“占全部样本”的比例,而不是“占有效样本”的比例。如果报告里需要区分,可以在计算前先筛掉NaN,或者把缺失单独列一行。
reindex(order)按指定顺序排列,顺序里没出现的选项会得到NaN,用fillna(0)填0。这样“无人选择”和“数据缺失”不会混在一起。最后一行手动加(缺失),这比默认的NaN直观得多。
3.2 多选题统计的两种口径:按人次与按人数
多选题的统计必须分清两个概念:选项被选了多少次,和多少人选了它。比如“功能A”被选了300次,但其中200次来自100个人重复选,这对统计“普及率”没有意义。平台一般不允许多选里重复勾同一项,但导入手工录入数据时可能出现,不去重会把人数虚高。
from collections import Counter all_lists = df["features_list"].dropna().tolist() # 把所有列表展平成一个长列表 all_items = [item for sublist in all_lists for item in sublist] # 被选次数:一个人选了两次也计入两次 times_count = Counter(all_items) # 人数:按人先去重,再统计 person_lists = df["features_list"].apply(lambda x: set(x)).dropna().tolist() person_count = Counter(item for sublist in person_lists for item in sublist) summary = pd.DataFrame({ "被选次数": times_count, "选择人数": person_count, }) summary.fillna(0, inplace=True) print(summary)all_lists先丢掉缺失值,避免空列表混进来。展平列表用双重循环,这是Python里最直接的写法。set(x)会去掉每个样本内部的重复选项,所以person_count统计的是“多少人至少选了一次”。最终表格里,两个口径要同时呈现,否则读者分不清你是按人次还是按人数。
如果问卷平台是0/1多列,可以先sum()得到人次,再gt(0).sum()得到人数:
multi_cols = [c for c in df.columns if c.startswith("features_")] choice_count = df[multi_cols].sum() # 人次 person_count = (df[multi_cols] > 0).sum() # 人数这个写法更短,但前提是列里确实只有0和1。出现2或者其他值时,gt(0)会把异常值也当成选中,所以清洗阶段的值域检查不能省。
3.3 交叉分析:crosstab与pivot_table的取舍
交叉分析最常见的场景是“性别×满意度”,看不同性别内部的满意比例。pandas里crosstab专门做频数交叉,pivot_table则适合算均值、求和等汇总统计。区别在于crosstab不需要指定聚合函数,pivot_table必须给aggfunc。
ct = pd.crosstab(df["gender"], df["satisfaction"], dropna=False) print(ct) # 行百分比,用于对比不同性别内部的比例 row_pct = ct.div(ct.sum(axis=1), axis=0) * 100 print(row_pct.round(2)) # 总百分比 total_pct = ct / ct.values.sum() * 100 print(total_pct.round(2))dropna=False在这里很关键,crosstab默认会把带NaN的行列剔除,导致结果表里少了“跳过性别”或“跳过满意度”的人群。如果只想看有效样本,就显式设置dropna=True并在报告里注明筛选条件。
ct.div(ct.sum(axis=1), axis=0)算行百分比时,axis=0代表按行对齐。如果把axis写成1,会按列除,结果就变成每列内部的比例,含义全变。写完后建议打印几行检查,比如看每一行是否加起来约等于100。
如果问卷里有李克特量表题,比如满意度映射成1到5分,用pivot_table更合适:
score_map = {"非常不满意": 1, "不满意": 2, "一般": 3, "满意": 4, "非常满意": 5} df["score"] = df["satisfaction"].map(score_map) pv = pd.pivot_table(df, values="score", index="gender", aggfunc="mean") print(pv.round(2))pivot_table的values必须是数值列,index支持多级索引,aggfunc还能传列表,比如aggfunc=["mean", "count"],一次算均值又算样本量。注意map时如果遇到没匹配上的选项,默认得到NaN,这正好帮我们发现数据里是否存在意料之外的文本。
4. 让统计结果变成报告:图表字体、Excel样式与Word输出
4.1 matplotlib中文字体:注册字体文件比改rcParams更稳
问卷统计至少要有饼图或条形图,而matplotlib默认字体没有中文字符,图一出来全是方块。网上最常教的是plt.rcParams['font.sans-serif']=['SimHei'],这在本地Windows有效,但换到公司内网服务器或同学电脑上,字体不存在还是会翻车。更稳的写法是先确认系统中文字体路径,或直接注册一个字体文件进来。
import matplotlib.pyplot as plt from matplotlib import font_manager # 常见中文字体路径,按需改成自己系统的实际路径 font_path = "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc" # Linux常见 # 或者使用 Windows 路径:C:/Windows/Fonts/msyh.ttc if font_path: font_manager.fontManager.addfont(font_path) prop = font_manager.FontProperties(fname=font_path) plt.rcParams["font.sans-serif"] = [prop.get_name()] plt.rcParams["axes.unicode_minus"] = False如果找不到字体文件,可以先用font_manager.fontManager.ttflist列出所有已注册字体,再挑一个含中文的。用addfont方式的好处是,即便系统没有预装,只要把ttc文件放到工程目录下,脚本也能稳定工作。axes.unicode_minus=False必须写,否则坐标轴上的负号会显示成方块。
画图本身不复杂,关键是把统计结果传进去:
import matplotlib.pyplot as plt plt.figure(figsize=(8, 5)) plt.bar(stat.index, stat["人数"], color="#4C72B0") plt.title("满意度分布") plt.xticks(rotation=30) plt.tight_layout() plt.savefig("满意度分布.png", dpi=200)tight_layout()能避免标题和坐标轴被裁掉。dpi=200适合直接贴进Word或PPT,太低会糊。
4.2 用openpyxl给统计表加格式:让数值表直接可发
pandas的to_excel能把DataFrame写进Excel,但默认没有任何格式。交给别人的统计表里,表头要加粗,列宽要调,关键数字可以标色。常见做法是先用pandas写原始表,再用openpyxl加载改样式。
from openpyxl import load_workbook from openpyxl.styles import Font, PatternFill stat.to_excel("统计结果.xlsx", sheet_name="单选统计", index=True) wb = load_workbook("统计结果.xlsx") ws = wb["单选统计"] # 表头加粗并填充浅灰色 for cell in ws[1]: cell.font = Font(bold=True) cell.fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") # 设置A列宽度,避免中文标题被截断 ws.column_dimensions["A"].width = 20 for col_letter in ["B", "C"]: ws.column_dimensions[col_letter].width = 12 wb.save("统计结果.xlsx")to_excel的index=True表示保留DataFrame索引,这正好把选项名写在A列。如果不想要索引,就把index=False,再用df.to_excel把索引转成普通列。openpyxl只能加载xlsx,不能加载旧的xls,如果你的文件是旧格式,先用pandas转一次。
4.3 生成Word版报告:python-docx适合交付
有人要Excel,有人要Word。Word报告生成不需要自己排版,python-docx可以把结论、表格、截图按顺序拼出来。关键是用add_table时先按列数建空表,再逐行填值。
from docx import Document doc = Document() doc.add_heading("2024年问卷统计报告", level=1) doc.add_paragraph("本次共回收有效问卷 {} 份。".format(len(df))) # 把单选题统计表写入Word table = doc.add_table(rows=1, cols=3) table.style = "Light Shading Accent 1" hdr = table.rows[0].cells hdr[0].text, hdr[1].text, hdr[2].text = "选项", "人数", "占比" for option, row in stat.iterrows(): cells = table.add_row().cells cells[0].text = option cells[1].text = str(int(row["人数"])) cells[2].text = f"{row['占比%']:.1f}%" doc.save("问卷统计报告.docx")每次add_row()都会返回一行新单元格,需要先把选项名转成字符串,否则非字符串类型会报错。Light Shading Accent 1是python-docx内置表格样式,如果不想依赖样式名,可以删掉style行,用默认样式。截图可以先用doc.add_picture("满意度分布.png")插入,再紧跟着写一段解读文字。
5. 问卷数据统计的5个常见坑:从数据错位到中文乱码的排查手册
问卷统计的坑集中在三个环节:读数据时类型被改、多选题被当成单选、图表中文乱码。下面5条是我反复踩过的,每条按“现象→原因→解决”记录。
5.1 选项顺序错乱:value_counts不按量表顺序排
现象:条形图出来“一般”在最前面,“非常满意”在最后,报告顺序看着很别扭。
原因:value_counts默认按频次降序,文本列不会按报表定义排序。
解决:用显式order列表重排,或者把列转成pd.Categorical并指定categories顺序。这样图标和交叉表都会跟随这个顺序。
cat = pd.Categorical(df["satisfaction"], categories=["非常不满意", "不满意", "一般", "满意", "非常满意"], ordered=True) df["satisfaction_cat"] = cat print(df["satisfaction_cat"].value_counts(dropna=False))排查时先看一眼当前列的所有取值,确认没有不可见空格:
print(df["satisfaction"].astype(str).unique())如果打印出来看到“非常满意 ”这样的带空格值,说明清洗阶段的strip()没生效,需要回头处理。别小看这个问题,它能让reindex后的缺失选项变成0,直接覆盖掉真实数据。
5.2 多选题漏算:直接把长文本丢给value_counts
现象:某个选项统计人数比平台后台少很多,而且少掉的都是选了多个选项的人。
原因:整列value_counts统计的是整条字符串,没有拆开。比如“功能A|功能C”会被当成一个独立选项,不会被计入“功能A”和“功能C”。
解决:先split再Counter,统计人数前用set去重。
排查时先看这列的样本长什么样:
print(df["features"].dropna().head(10)) # 再看拆分后的列表是否包含所有预期选项 from collections import Counter all_items = [item for sublist in df["features_list"] for item in sublist] print(set(all_items))如果发现“功能A”和“功能A ”同时存在,说明分隔符前后的空格没处理干净。如果发现“功能C”完全没有出现,可能是这个选项只在某个少见分隔符后面,比如用了“、”而不是“|”。最省事的办法是把所有可能的分隔符统一替换成“|”,再做split。
5.3 量表均值算错:负值和0混进评分
现象:满意度平均分算出2.3,但分发问卷的人说结果没那么差。
原因:平台把“未作答”记为-1,把“跳过”记为0,这些值参与了mean()运算。你看到的平均分,是被无效负值拉低的。
解决:先用pd.to_numeric(errors="coerce")把非数值转NaN,再对分数范围做约束。
df["score_num"] = pd.to_numeric(df["score"], errors="coerce") df.loc[~df["score_num"].between(1, 5), "score_num"] = pd.NA print(df["score_num"].mean())排查时先看value_counts有哪些奇怪值:
print(df["score"].value_counts(dropna=False).sort_index())如果看到-1、0或者6,这些都要统一处理。between(1, 5)会筛掉所有不在量表范围内的值。注意errors="coerce"本身会丢掉无法转成数字的文本,但不会丢掉合法的-1,所以约束条件必须自己写。
5.4 手机号变科学计数法,日期变成一串秒
现象:导出表里手机号列显示成9.18E+10,日期列变成datetime带时分秒,统计时一排序就乱套。
原因:pandas的read_excel会自动推断类型,长数字被当成浮点,日期被当成时间戳。
解决:读文件时用dtype=str,这是成本最低的后悔药。
df_raw = pd.read_excel("问卷.xlsx", dtype=str)如果已经用错误类型读进来了,再想恢复长整型手机号很难,因为浮点精度已经把尾数改掉了。所以我的习惯是:只要是从问卷平台导出的表,一律dtype=str读入,后续按列转类型。日期列如果确实需要分析,再用pd.to_datetime(df["提交时间"], format="%Y-%m-%d %H:%M:%S")转换。
5.5 中文字体在不同机器上消失
现象:本机画图正常,同一段脚本放到服务器跑,输出的png全是方块。
原因:服务器没有安装中文字体,matplotlib找不到可用的字体文件。
解决:不要只依赖系统已有字体,用font_manager.addfont指定随脚本带的字体文件,或者先装好wqy-microhei。
排查时列出所有已注册字体,看哪些含中文:
from matplotlib import font_manager names = {f.name for f in font_manager.fontManager.ttflist} print([n for n in names if "Hei" in n or "YaHei" in n or "WenQuanYi" in n])如果列表为空,就去下载一个开源中文字体文件放到工程目录,再用addfont注册。这一步做完,脚本换到哪台机器都不会再因为字体翻车。
6. 把统计脚本变成可复用的模板:函数封装与批处理技巧
6.1 用配置字典驱动统计函数
如果每次拿到新问卷都重新写一遍清洗和统计,那前面节省的时间又会被浪费。常见做法是把统计逻辑封装成函数,用配置字典驱动。这样换问卷时只改配置,不动代码。
def single_freq(series, order=None): """单选频次统计,按给定顺序输出""" freq = series.value_counts(dropna=False) pct = series.value_counts(normalize=True, dropna=False) * 100 stat = pd.DataFrame({"人数": freq, "占比%": pct.round(2)}) if order: stat = stat.reindex(order).fillna(0) return stat def multi_expand(series, splitter="|"): """把多选文本拆成列表""" return series.apply( lambda x: [i.strip() for i in str(x).split(splitter) if i.strip()] if pd.notna(x) else [] )使用示例:
single_config = { "satisfaction": ["非常不满意", "不满意", "一般", "满意", "非常满意"], "gender": ["男", "女", "其他"], } for name, order in single_config.items(): result = single_freq(df[name], order) result.to_excel(f"{name}_统计.xlsx")把清洗步骤放在主流程,统计函数只接收已经干净的Series,这样每个函数只干一件事,出错时也容易定位。
6.2 这套模板的边界在哪里
这套模板能解决频次和交叉统计,但不解决主观题文本聚类、量表加权、信度效度计算。如果问卷里大量开放题,需要另上NLP;如果涉及李克特量表,先读一点心理测量学,别直接按均值排名。批量统计时还要注意:不同批次问卷的题号可能不同,配置字典里的列名要仔细核对。
我之前有次拿平台导出的原始表直接跑了均值,结果被某导师一句“这个负分哪来的”问住,翻车之后才认真写了前面的清洗前置检查。现在拿到任何问卷导出表,第一件事永远是先打印shape、columns和value_counts,确认没有脏值再往下走。做问卷统计最值钱的部分不是pandas函数,而是你愿不愿意花十分钟确认数据没被平台“预加工”过。希望帮到你。
本文还有配套的精品资源,点击获取