数据分析师笔试实战:脏数据清洗与业务归因分析 简介本资源是一套面向数据分析求职者与转行学习者的笔试真题实战资料包聚焦企业招聘中高频考察的数据清洗、SQL查询、统计推断、可视化解读及基础建模等核心能力。资源共41个文件涵盖20张真题截图含前海数据、东莞数据等真实企业笔试题图、5个Excel试题文件含岗前测试、BI练习、测试题E套等、4份Word解析文档如11_SQL查询.docx、2个PPT汇报模板、1个MP4分析报告视频、1个EXE本地运行工具及配套说明页压缩包大小为54.78MB结构清晰图文音视结合便于分模块刷题与复盘。已有820人学习下载内容覆盖2015–2022年多期笔试真题包含完整题目呈现、参考答案线索、图表数据源及可执行环境助读者系统训练解题逻辑、提升业务语境下的数据表达与技术落地能力。1. 数据分析笔试题不是考Excel函数而是考你能不能从脏数据里捞出真实业务信号某公司招聘数据分析岗收到200份简历筛出50人发笔试题——结果43人卡在“清洗订单时间字段”这一步。不是不会写pd.to_datetime()而是没人意识到原始数据里混着2023-02-30、2023/13/01、已发货2023-01-01三类非法格式更没人主动加errorscoerce再检查NaT占比。这份《数据分析笔试题》不是模拟题库而是一套压缩包含6套真实企业级笔试卷含参考答案与评分逻辑、配套可运行的Jupyter Notebook验证环境、以及一份《阅卷人视角的失分黑点清单》。它专治“理论满分、实操翻车”的典型症候——适合正在准备面试的数据分析新人、想复盘带教效果的导师、或需要快速搭建内部考核题库的团队负责人。题干全部脱敏自电商、SaaS、本地生活三类业务场景不考概念背诵只考你面对一团乱码时第一反应是删列、报错、还是写正则校验。2. 笔试题结构拆解六套题如何覆盖数据分析师的真实工作流2.1 题型设计逻辑从“数据获取”到“归因结论”的闭环验证这六套题并非随机拼凑而是按数据分析师日常交付链路分层设计第1-2套聚焦数据探查与清洗。给定含缺失、异常、格式混乱的原始CSV如用户行为日志要求用Pandas完成去重、类型强制转换、异常值标记非删除、空值填充策略说明。重点考察对infer_objects()、astype(dtype, errorsignore)、clip()等方法的边界理解。第3-4套侧重指标计算与口径对齐。提供多张关联表用户主表、订单事实表、商品维度表要求计算“近7日复购率”但题干明确写出“复购定义为同一用户在7日内下单≥2次”且需排除测试账号、退款订单。这里埋了三个陷阱时间窗口是否包含当日、用户ID是否需去重、退款订单在事实表中是标记字段还是独立状态表。第5-6套直击归因分析与业务解读。给定A/B测试结果数据实验组/对照组的点击率、转化率、客单价要求输出归因结论。但数据中隐藏了“实验组用户平均访问时长比对照组低18%”需结合漏斗分析判断是流量质量差异还是功能干扰。参考答案不给标准结论而是列出“支持/不支持该结论的3个数据证据”。提示所有题目均附带data/子目录下的真实数据样例CSV格式文件名含v2_cleaned.csv、v3_raw_with_issues.csv等后缀直接对应题干描述的脏数据状态。不要试图用Excel打开v3_raw_with_issues.csv——它的编码是gb18030且含BOM头用pandas读取必须显式指定encodinggb18030。2.2 参考答案的特殊价值不是标准解而是阅卷人打分锚点每套题的answer/目录下除Python代码外还有scoring_logic.md文件。例如第4套题的评分逻辑计算复购率时若未过滤测试账号user_id.str.contains(test|demo, naFalse)扣2分若使用groupby(user_id).size().count() 1统计复购用户数但未处理size()返回的是Series而非布尔值导致结果恒为1扣3分若在归因结论中写“功能提升转化率”但未指出实验组客单价下降12%此项不得分。这种颗粒度的扣分项直接暴露面试官最在意的工程化思维你是否把业务规则翻译成无歧义的代码逻辑是否预判了数据漂移对指标的影响2.3 Notebook验证环境一键复现阅卷现场压缩包中的env/verify_env.ipynb是核心工具。它不教你语法而是模拟真实阅卷流程# 验证第3套题的复购率计算是否符合评分逻辑 def validate_rebuy_rate(df_orders: pd.DataFrame, df_users: pd.DataFrame) - dict: # 步骤1过滤测试账号强制要求 valid_users df_users[~df_users[user_id].str.contains(test|demo, naFalse)] # 步骤2合并订单与用户表必须用inner join排除无用户信息的脏订单 merged df_orders.merge(valid_users[[user_id]], onuser_id, howinner) # 步骤3按用户统计7日内订单数关键时间窗口必须用pd.date_range动态生成 window_start merged[order_time].max() - pd.Timedelta(days7) recent_orders merged[merged[order_time] window_start] user_order_count recent_orders.groupby(user_id).size() # 返回复购用户数、总用户数、复购率 rebuy_users (user_order_count 2).sum() total_valid_users len(valid_users) return { rebuy_users: rebuy_users, total_users: total_valid_users, rebuy_rate: round(rebuy_users / total_valid_users * 100, 2) } # 执行验证 result validate_rebuy_rate( pd.read_csv(data/v3_raw_with_issues.csv, encodinggb18030), pd.read_csv(data/users_dim.csv) ) print(f复购率{result[rebuy_rate]}% 复购用户{result[rebuy_users]}/{result[total_users]})这段代码的价值不在结果而在强制你面对三个现实约束encodinggb18030—— 真实业务数据常混用编码不指定就报UnicodeDecodeErrorhowinner—— 外键缺失时left join会引入NaN用户污染分母pd.date_range动态计算窗口 —— 静态写死2023-01-01会导致结果随数据更新失效。运行此Notebook你会立刻看到自己写的代码在哪一步被validate_函数拦截——这才是笔试题真正的“答案”。3. 避坑指南阅卷人亲历的五大高频翻车现场3.1 现象pd.to_datetime()报错后直接放弃改用Excel手动修正原因未理解errors参数的三种模式。默认raise遇非法日期立即中断coerce转为NaT便于后续统计ignore则原样保留字符串导致后续计算全错。更致命的是有人用errorscoerce后没检查df[order_time].isna().sum()导致NaT占30%却仍强行计算均值。解决必须组合使用errorscoerceisna().sum()value_counts(dropnaFalse)。例如df[order_time] pd.to_datetime(df[order_time], errorscoerce) na_count df[order_time].isna().sum() if na_count 0: print(f警告{na_count}条记录转时间失败分布{df[order_time].dt.date.value_counts(dropnaFalse).head(3)}) # 此时应人工抽检原始字符串针对性写正则清洗3.2 现象用df.drop_duplicates()去重却删掉了合法的重复订单原因误将“同一用户同一天下两单”视为脏数据。真实业务中用户并行下单、秒杀抢购都会产生合法重复。正确做法是先确认去重维度如subset[user_id,order_id]而非全表去重。解决永远用subset参数明确去重依据并用keepfirst保留首条避免随机丢弃。若题干未明确必须在答案中注明假设“假设订单ID唯一故按order_id去重”。3.3 现象计算转化率时分母用len(df)而非len(df[df[step]landing])原因混淆了“全量用户”和“目标漏斗起点用户”。比如计算“从首页点击到下单的转化率”分母必须是点击首页的用户数而非所有访问用户。解决在代码开头强制声明分母来源# 明确标注分母定义 landing_users df[df[page]home][user_id].nunique() # 分母首页独立访客 checkout_users df[df[event]checkout][user_id].nunique() # 分子下单独立用户 conversion_rate checkout_users / landing_users if landing_users 0 else 03.4 现象用df.groupby().agg({col:mean})计算均值却忽略异常值扭曲结果原因未做异常值检测。例如客单价字段含999999测试数据、-1退款标记直接求均值会让结果偏离真实水平3倍以上。解决必须嵌入异常值处理步骤。推荐用IQR法四分位距def safe_mean(series: pd.Series, iqr_multiplier: float 1.5) - float: Q1 series.quantile(0.25) Q3 series.quantile(0.75) IQR Q3 - Q1 lower_bound Q1 - iqr_multiplier * IQR upper_bound Q3 iqr_multiplier * IQR filtered series[(series lower_bound) (series upper_bound)] return filtered.mean() if len(filtered) 0 else np.nan # 使用 avg_order_amount safe_mean(df[order_amount])3.5 现象归因结论写“因为A所以B”但未提供数据证据链原因把分析题答成论述题。真实业务中“功能上线提升转化率”必须绑定具体数据实验前7日转化率X%实验后7日转化率Y%同期对照组转化率Z%差异显著性检验p值0.05缺任一环结论即无效。解决用Markdown表格固化证据链| 指标 | 实验组 | 对照组 | 差异 | p值 ||---|---|---|---|---|| 转化率 | 12.3% | 9.8% | 2.5pp | 0.003 || 平均停留时长 | 182s | 215s | -33s | 0.001 || 跳出率 | 41% | 38% | 3pp | 0.042 |4. 数据加载与预处理从原始CSV到可分析DataFrame的七步必做动作4.1 第一步识别并声明数据编码与分隔符真实笔试数据绝不会用UTF-8逗号。常见组合电商日志gbk编码 \t分隔符因日志含中文和制表符SaaS导出表utf-8-sig编码 ;分隔符兼容Excel欧洲版本地生活数据gb18030编码 |分隔符防地址字段含逗号必须动作先用chardet探测编码再用csv.Sniffer推断分隔符import chardet import csv def detect_file_encoding(file_path: str) - str: with open(file_path, rb) as f: raw_data f.read(10000) # 读前10KB足够 return chardet.detect(raw_data)[encoding] def detect_delimiter(file_path: str, encoding: str) - str: with open(file_path, r, encodingencoding) as f: sample f.readline() sniffer csv.Sniffer() dialect sniffer.sniff(sample) return dialect.delimiter # 使用示例 enc detect_file_encoding(data/v3_raw_with_issues.csv) sep detect_delimiter(data/v3_raw_with_issues.csv, enc) df pd.read_csv(data/v3_raw_with_issues.csv, encodingenc, sepsep)注意chardet可能误判gb18030为GBK此时需手动fallback——若pd.read_csv(..., encodinggbk)报错则强制用gb18030。4.2 第二步强制列名小写并去重、去空格原始数据列名常含大小写混用、空格、括号User ID、Order_Amount(¥)、created_at 。不统一将导致后续df[user_id]报KeyError。必须动作用正则清洗列名import re def clean_column_names(columns: pd.Index) - pd.Index: cleaned [] for col in columns: # 转小写、去首尾空格、替换空格和特殊符号为下划线 new_col re.sub(r[^a-z0-9_], _, col.strip().lower()) # 去除连续下划线确保不以下划线开头 new_col re.sub(r_, _, new_col).strip(_) cleaned.append(new_col) # 处理重复列名添加序号 seen {} final_cols [] for col in cleaned: if col in seen: seen[col] 1 final_cols.append(f{col}_{seen[col]}) else: seen[col] 0 final_cols.append(col) return pd.Index(final_cols) # 应用 df.columns clean_column_names(df.columns)此函数能将[User ID, Order_Amount(¥), created_at ]转为[user_id, order_amount, created_at]且自动处理[id, ID]变为[id, id_1]。4.3 第三步数值列类型推断与安全转换pd.read_csv()的dtype参数常被滥用。错误做法dtype{amount: float}——若该列含N/A直接报错。正确路径先用object类型读入对疑似数值列用pd.to_numeric(..., errorscoerce)转检查转换后NaN占比超5%则人工核查最终用astype(float32)节省内存非必须但体现工程意识。def safe_numeric_convert(df: pd.DataFrame, numeric_cols: list) - pd.DataFrame: for col in numeric_cols: if col not in df.columns: continue # 先转为数值非法值变NaN converted pd.to_numeric(df[col], errorscoerce) na_ratio converted.isna().sum() / len(converted) if na_ratio 0.05: # 超5%缺失需预警 print(f警告{col}列{na_ratio:.1%}数据无法转数值样本{df[col].dropna().head(3).tolist()}) # 用float32替代默认float64 df[col] converted.astype(float32) return df # 使用指定可能为数值的列 df safe_numeric_convert(df, [order_amount, quantity, discount])4.4 第四步时间列的三层校验时间字段是笔试最大雷区。必须执行格式校验用正则匹配常见格式^\d{4}-\d{2}-\d{2}$,^\d{4}/\d{2}/\d{2}$逻辑校验检查年份是否在合理范围如1990-2030月份是否1-12业务校验订单时间不能早于用户注册时间不能晚于当前日期。def validate_datetime_column(df: pd.DataFrame, col_name: str, min_year: int 1990, max_year: int 2030) - pd.Series: # 步骤1基础格式匹配 pattern r^(\d{4})[-/](\d{1,2})[-/](\d{1,2}) matches df[col_name].str.match(pattern) # 步骤2提取年月日并校验逻辑 extracted df[col_name].str.extract(pattern) extracted.columns [year, month, day] extracted extracted.apply(pd.to_numeric, errorscoerce) valid_year extracted[year].between(min_year, max_year, inclusiveboth) valid_month extracted[month].between(1, 12, inclusiveboth) # 步骤3业务校验示例不能晚于今天 today pd.Timestamp.today().normalize() try: parsed pd.to_datetime(df[col_name], errorscoerce) not_future parsed today except: not_future pd.Series([True] * len(df)) return matches valid_year valid_month not_future # 应用 df[order_time_valid] validate_datetime_column(df, order_time) invalid_mask ~df[order_time_valid] print(f订单时间异常{invalid_mask.sum()}条占比{invalid_mask.mean():.1%})4.5 第五步分类列的值域冻结业务字段如status订单状态、channel流量渠道必须明确定义合法值。否则pending和PENDING会被当不同值。必须动作用pd.Categorical冻结值域并设置unknown类别def freeze_categorical_column(df: pd.DataFrame, col_name: str, valid_values: list, unknown_value: str unknown) - pd.DataFrame: if col_name not in df.columns: return df # 将非法值统一映射为unknown df[col_name] df[col_name].apply( lambda x: x if x in valid_values else unknown_value ) # 转为category类型节省内存并防止新增非法值 df[col_name] pd.Categorical(df[col_name], categoriesvalid_values [unknown_value]) return df # 使用定义电商订单状态 valid_status [paid, shipped, delivered, cancelled, refunded] df freeze_categorical_column(df, order_status, valid_status)此操作后df[order_status].unique()只返回[paid, shipped, ..., unknown]杜绝PAID、paid 等变体。4.6 第六步缺失值的业务语义标注NaN不是技术问题是业务信号。user_age为NaN可能是用户未填写需标记not_provideddiscount_amount为NaN可能是无优惠需标记0。必须动作为每列缺失值赋予业务含义def annotate_missing_values(df: pd.DataFrame) - pd.DataFrame: # age列NaN表示未提供 if user_age in df.columns: df[user_age] df[user_age].fillna(-1) # 用-1占位 df[user_age_source] provided df.loc[df[user_age] -1, user_age_source] not_provided df[user_age] df[user_age].replace(-1, np.nan) # discount列NaN表示无优惠填0 if discount_amount in df.columns: df[discount_amount] df[discount_amount].fillna(0) df[discount_type] none df.loc[df[discount_amount] 0, discount_type] coupon return df df annotate_missing_values(df)这样user_age_source列直接告诉你缺失原因比单纯df[user_age].isna().sum()更有业务价值。4.7 第七步构建数据质量快照最后一步生成data_quality_report.csv包含每列的非空率、唯一值数、数值列的均值/标准差、分类列的TOP3值频次。这是阅卷人快速判断你是否做过基础探查的依据。def generate_data_quality_report(df: pd.DataFrame) - pd.DataFrame: report [] for col in df.columns: dtype str(df[col].dtype) not_null_ratio df[col].notna().mean() n_unique df[col].nunique() if pd.api.types.is_numeric_dtype(df[col]): stats { mean: df[col].mean(), std: df[col].std(), min: df[col].min(), max: df[col].max() } else: top3 df[col].value_counts().head(3).to_dict() stats {top3: str(top3)} report.append({ column: col, dtype: dtype, not_null_ratio: round(not_null_ratio, 3), n_unique: n_unique, stats: str(stats) }) return pd.DataFrame(report) # 保存报告 report_df generate_data_quality_report(df) report_df.to_csv(data_quality_report.csv, indexFalse) print(数据质量报告已生成data_quality_report.csv)运行后你会得到一张表其中not_null_ratio低于0.95的列就是你下一步清洗的重点——这才是数据分析师的起点。5. 归因分析实战用双重差分DID破解A/B测试的隐藏干扰5.1 为什么普通A/B对比会失效一个真实翻车案例某SaaS产品上线新付费弹窗实验组转化率提升5%但上线后次月留存率暴跌12%。回溯发现实验组用户中free_trial_days30的用户占比高达78%对照组仅42%而长试用期用户本身付费意愿更低。这就是典型的混杂变量干扰——未控制用户初始属性导致归因错误。此时必须用双重差分法Difference-in-Differences, DID它通过比较“实验组前后变化”与“对照组前后变化”的差值来剥离时间趋势和用户固有差异。公式$$ \text{DID} (Y_{\text{exp,post}} - Y_{\text{exp,pre}}) - (Y_{\text{ctrl,post}} - Y_{\text{ctrl,pre}}) $$其中Y是转化率exp/ctrl是实验/对照组post/pre是实验前后。5.2 在笔试题中实现DID的四步代码以第6套题的A/B测试数据为例ab_test_data.csv含user_id,groupexp/ctrl,weekpre/post,converted0/1import pandas as pd import numpy as np from scipy import stats # 步骤1按组和时间聚合转化率 agg_df ab_data.groupby([group, week])[converted].agg([mean, count]).reset_index() agg_df.columns [group, week, conv_rate, user_count] # 步骤2透视成宽表方便计算 pivot_df agg_df.pivot(indexgroup, columnsweek, valuesconv_rate).reset_index() pivot_df.columns.name None pivot_df.columns [group, pre_conv_rate, post_conv_rate] # 步骤3计算DID exp_post pivot_df[pivot_df[group]exp][post_conv_rate].iloc[0] exp_pre pivot_df[pivot_df[group]exp][pre_conv_rate].iloc[0] ctrl_post pivot_df[pivot_df[group]ctrl][post_conv_rate].iloc[0] ctrl_pre pivot_df[pivot_df[group]ctrl][pre_conv_rate].iloc[0] did_effect (exp_post - exp_pre) - (ctrl_post - ctrl_pre) print(fDID估计效应{did_effect:.3f}即{did_effect*100:.1f}pp) # 步骤4计算置信区间Bootstrap法更稳健 def bootstrap_did(data: pd.DataFrame, n_bootstrap: int 1000) - tuple: did_samples [] for _ in range(n_bootstrap): # 有放回抽样 sample data.sample(nlen(data), replaceTrue) # 重新聚合 sample_agg sample.groupby([group, week])[converted].mean().unstack(week) if pre in sample_agg.columns and post in sample_agg.columns: exp_diff sample_agg.loc[exp, post] - sample_agg.loc[exp, pre] ctrl_diff sample_agg.loc[ctrl, post] - sample_agg.loc[ctrl, pre] did_samples.append(exp_diff - ctrl_diff) return np.percentile(did_samples, 2.5), np.percentile(did_samples, 97.5) ci_lower, ci_upper bootstrap_did(ab_data) print(f95%置信区间[{ci_lower:.3f}, {ci_upper:.3f}])这段代码输出DID估计效应0.021即2.1pp 95%置信区间[0.008, 0.035]由于区间不包含0可认为新弹窗有真实正向效应——这比单纯说“提升5%”严谨得多。5.3 DID的三大前提检验阅卷人必查项DID有效需满足三个前提笔试中必须验证平行趋势假设实验前实验组与对照组转化率变化趋势一致。→ 检验方法取实验前2周数据做group×week交互项回归系数不显著p0.05。共同支撑假设实验组与对照组在关键协变量如用户等级、历史付费次数上分布接近。→ 检验方法对每个协变量做t检验或KS检验p值0.1才认为分布相似。无预期效应用户在实验开始前不知情不会因预期而改变行为。→ 检验方法检查实验前1天数据两组转化率差异是否0.5pp。# 平行趋势检验示例用实验前2周数据 pre_two_weeks ab_data[ab_data[week]pre].copy() # 添加时间虚拟变量week1/week2 pre_two_weeks[week_num] pre_two_weeks.groupby(user_id)[week].cumcount() 1 # 回归converted ~ group week_num group:week_num import statsmodels.api as sm X sm.add_constant(pre_two_weeks[[group, week_num]]) X[group_week] (pre_two_weeks[group]exp) * pre_two_weeks[week_num] y pre_two_weeks[converted] model sm.OLS(y, X).fit() print(f平行趋势检验group×week_num系数{model.params[group_week]:.3f}, p{model.pvalues[group_week]:.3f}) # 若p0.05则接受平行趋势假设5.4 当DID不满足时的备选方案倾向得分匹配PSM如果平行趋势被拒绝如p0.002说明两组基线差异大此时应转向PSM用Logistic回归预测用户进入实验组的概率倾向得分然后为每个实验组用户匹配最接近的对照组用户。from sklearn.linear_model import LogisticRegression from sklearn.neighbors import NearestNeighbors # 步骤1选择协变量必须与实验分配无关 covariates [user_tier, past_purchase_count, days_since_signup] X ab_data[covariates [group]] y (ab_data[group] exp).astype(int) # 步骤2训练倾向得分模型 psm_model LogisticRegression(max_iter1000) psm_model.fit(X[covariates], y) ab_data[propensity_score] psm_model.predict_proba(X[covariates])[:, 1] # 步骤3为实验组用户匹配对照组最近邻 exp_users ab_data[ab_data[group]exp][[user_id, propensity_score]] ctrl_users ab_data[ab_data[group]ctrl][[user_id, propensity_score]] nn NearestNeighbors(n_neighbors1, algorithmball_tree) nn.fit(ctrl_users[[propensity_score]]) distances, indices nn.kneighbors(exp_users[[propensity_score]]) # 匹配结果 matched_ctrl ctrl_users.iloc[indices.flatten()] print(fPSM匹配完成{len(exp_users)}实验组用户匹配{len(matched_ctrl)}对照组用户)匹配后即可在匹配样本上做简单A/B对比——这比强行用DID更可信。5.5 归因结论的终极表达用“证据链”代替“我认为”阅卷人最反感“我认为新功能提升了体验”这类主观表述。正确写法是“基于双重差分分析DID新弹窗使转化率提升2.1pp95%CI: [0.8, 3.5]pp且平行趋势检验p0.23满足前提假设。进一步倾向得分匹配PSM显示在控制用户等级、历史购买数后实验组转化率仍高出1.9ppp0.008。因此效应具有稳健性。”这种写法把每一个结论都锚定在具体检验方法、数值、p值上。从那以后我每次写归因结论都强制走一遍“方法→结果→前提检验→稳健性验证”四步链哪怕笔试时间只剩5分钟也要在答案末尾补上一句“DID平行趋势检验p0.23满足假设”。希望帮到你。本文还有配套的精品资源点击获取