简介:这是一份面向Python数据处理学习者与高校课程复习场景的作业资料包,内容围绕北邮Python课程中的学习笔记、复习巩固与大作业展开,既适合初学者梳理语法、变量、控制流、函数、模块与异常处理,也适合进阶者练习列表、字典、文件操作及数据清洗、预处理、分析和可视化。压缩包共103个文件,约84.46MB,以py脚本、ipynb笔记本、csv数据集和png结果图为主,辅以cfg/json配置、pdf/md说明以及少量pyc、html、log文件。代码与笔记本便于复现实验,csv提供天气、空气质量等实战数据,图片和文档可用于核对输出与整理思路。已有96人学习下载。资料将课堂笔记、练习题解法、综合项目与配套数据组织在一起,读者可据此复盘完整数据处理流程、运行示例、查看可视化结果,并借助配置与说明排查环境问题。
1. 从一份 Python 数据处理作业包说起:它到底能省掉哪三小时
某开发者把一个课程作业压缩包丢过来时,里面只有一个 data 目录、三个 py 脚本和一份没写注释的 requirements。这类 Python 数据处理作业包的价值不在算法多新,而在于它把「读文件—清洗—分组—出表出图」这条链路完整走了一遍:CSV 编码、Excel 引擎、缺失值判定、类型兜底、groupby 索引、中文绘图,每个点背后都是一次真实的翻车现场。它适合两类人:正在补 pandas 基本功的新手,能照着跑通一遍建立手感;以及手头有脏表要洗的从业者,把它当模板改字段名就能用。下面按「先跑通、再拆链路、最后记坑」的顺序过一遍。
2. 环境与目录先落地:pandas 版本、编码、依赖三件事定生死
拿到这类压缩包,最常见的错误是直接双击 py 文件或者用系统自带的 Python 跑,结果一半时间耗在「明明代码没错却报 ModuleNotFoundError」上。数据处理脚本对环境比一般脚本敏感:pandas 大版本之间groupby的默认行为、read_csv的dtype推断、to_excel的引擎参数都改过,用错版本跑出来的结果表和别人的对不上,这种问题排查起来最费劲。所以第一步不是读代码,是把目录看明白、把解释器隔离出来、把依赖钉死,这三件事做完再谈逻辑。
2.1 解开压缩包先看目录:哪些要改,哪些别动
解压后先tree一遍,别急着运行。一个结构清楚的作业包通常长这样,我用一张表把每个位置的职责和改动建议列出来:
| 路径 | 职责 | 是否建议改动 |
|---|---|---|
data/sales_raw.csv | 原始订单明细,含中文列名与脏值 | 只读,不改 |
data/users.csv | 用户维表,用于 merge 补字段 | 只读,不改 |
data/orders.xlsx | 另一份手工录入的表,需 openpyxl 读取 | 只读,不改 |
src/clean.py | 清洗主逻辑:缺失值、类型、去重 | 可改,改前先备份 |
src/analyze.py | 分组聚合与透视表 | 可改 |
src/plot.py | 出图,含中文字体设置 | 可改 |
run_all.py | 入口,串起上面三个 | 优先看这个 |
output/ | 结果落盘位置 | 保持目录存在 |
判断「哪些别动」有个简单标准:data/下的原始文件永远只读。我见过太多人为了省事直接拿 Excel 打开原始 CSV 改两格再保存,结果编码从 utf-8 变成带 BOM 的格式,后面read_csv读出来的第一列列名前面多一个不可见字符,df['订单号']直接 KeyError,找半小时找不到原因。真要清洗,一律在代码里做,输出到output/,原始文件留作对照。
再看入口脚本run_all.py。先别管里面写了什么,用文本编辑器搜三个东西:read_csv、to_excel、plt.savefig。这三个位置决定了输入输出路径怎么配、编码怎么给,后面调参数基本都围绕它们改。
2.2 用 venv 隔离解释器:三步建出干净环境
不要在系统 Python 里pip install。这类作业包通常依赖 pandas、numpy、openpyxl、matplotlib 四件套,版本一冲突,污染的是你所有项目。用 venv 建一个独立环境,出问题直接删目录重来,这是最便宜的后悔药。
# 在工作目录下解压,注意 -d 指定目标目录,避免散落一地 unzip data_processing_hw.zip -d data_processing_hw cd data_processing_hw # 建虚拟环境,.venv 是目录名,可自定 python -m venv .venv # 激活:Linux / macOS source .venv/bin/activate # 激活:Windows PowerShell # .venv\Scripts\Activate.ps1 # 确认当前解释器指向虚拟环境内部 python -c "import sys; print(sys.executable)"逻辑说明:python -m venv用当前解释器复制一份干净的 site-packages 目录,之后所有pip install都只装进.venv/lib,不碰全局。最后那句print(sys.executable)是关键自检——输出的路径里必须含.venv,否则说明激活没生效,后面装的包全进了系统环境,属于典型的「装完了但还是找不到模块」。
参数说明:unzip -d后面跟目标目录,不加会把压缩包里的文件全铺在当前目录;python -m venv若报ensurepip is not available,在 Debian 系上需要先装python3-venv包。Windows 上如果 PowerShell 拦执行策略,用cmd里的.venv\Scripts\activate.bat更省事。
激活成功后命令行提示符前会出现(.venv),这时候再装依赖。
2.3 依赖安装:requirements 锁到什么粒度
压缩包里的requirements.txt如果只写了pandas,那等于没锁。我一般改成带上下界的写法,既不锁死小版本导致装不上,也不放任大版本漂移:
pandas>=2.0,<3.0 numpy>=1.24,<3.0 openpyxl>=3.1 matplotlib>=3.7 chardet>=5.0openpyxl是读.xlsx的引擎,pandas 自己不实现 xlsx 解析,缺了它read_excel会直接抛ImportError,报错信息里明确写着缺哪个引擎。chardet用于探测 CSV 编码,遇到来源不明的文件时比自己猜 gbk 还是 utf-8 靠谱。
# 升级 pip 自身,老版本 pip 解析依赖容易出错 python -m pip install -U pip # 按清单安装 pip install -r requirements.txt # 装完立刻导出实际版本,作为本次可复现的凭据 pip freeze > requirements.lock.txt逻辑说明:pip freeze导出的lock文件记录了这次实际装上的精确版本号。作业包这类东西,跑通一次就够了,但半年后想再跑一次对结果,靠requirements.txt的宽范围是不够的,lock文件才是那份能复现的存档。
参数说明:-U是 upgrade,只作用于 pip 自身;-r指定清单文件。如果公司内网走私有源,在命令后加-i <源地址>,别去改全局 pip 配置。
安装完成后做一次冒烟测试,确认四个核心包都能正常导入并打印版本:
import pandas as pd import numpy as np import openpyxl import matplotlib print(pd.__version__, np.__version__, openpyxl.__version__, matplotlib.__version__)四行版本号都打出来,说明依赖链路通了。有一行报错就回去看是哪一步装漏了,不要在代码里用 try/except 掩盖导入失败。
2.4 第一次跑通:参数从哪来、输出落哪
先看入口脚本有没有接argparse。有就按它的参数跑,没有就改文件顶部的常量。规范的入口一般长这样:
# 带参数运行,输入、输出目录、编码都可控 python run_all.py \ --input data/sales_raw.csv \ --outdir output \ --encoding utf-8-sig如果脚本没有参数化,就在run_all.py开头找到路径常量,改这两行即可:
INPUT_PATH = "data/sales_raw.csv" # 原始文件,只读 OUTPUT_DIR = "output" # 结果目录,需提前存在逻辑说明:--encoding utf-8-sig里的sig指 BOM 标记。Windows 上用 Excel 另存过的 CSV 大多带 BOM,用纯utf-8读会在首列列名前多出\ufeff,表现为「列名看起来一样但取不到」。utf-8-sig会主动吃掉 BOM,对不带 BOM 的文件也兼容,所以作为默认值最稳妥。
参数说明:--outdir指定的目录必须已存在,to_excel和savefig都不会自动建父目录,缺目录时报FileNotFoundError。跑之前先mkdir -p output。
跑完检查output/下是否出现三类产物:清洗后的明细表、聚合结果表、图片。三样齐了说明链路通,接下来才是逐个模块拆开看逻辑。
3. 数据清洗主链路:读入、缺失值、类型、去重四步怎么落地
清洗是这类作业包里最值钱的部分,也是最容易写成一锅粥的部分。很多脚本把所有处理堆在一个函数里,读入、填充、转换、去重混着写,出了问题根本定位不到是哪一步把数据改坏的。我的习惯是拆成四段:读入定 schema、缺失值先统计后处理、类型转换带兜底、去重带主键校验。每段之间有明确的输入输出,任何一段出问题都能单独拿出来验。
3.1 读入就把 dtype 和 parse_dates 定死
read_csv最坑的地方是自动类型推断。订单号如果是纯数字,pandas 会推成 int64,前导零直接丢掉;金额列里混了一个"1,234.5",整列变 object;日期列十有八九读成字符串。推断这事儿看着智能,实际是黑匣子,所以能在读入阶段定死的就别留给推断。
import pandas as pd df = pd.read_csv( "data/sales_raw.csv", encoding="utf-8-sig", # 兼容带 BOM 的文件 dtype={ "订单号": "string", # 强转字符串,保住前导零 "用户ID": "string", "城市": "category", # 低基数列用 category 省内存 }, parse_dates=["下单时间"], # 直接解析成 datetime64 na_values=["", "NULL", "null", "-", "无"], # 统一空值标记 keep_default_na=True, ) print(df.shape) print(df.dtypes)逻辑说明:dtype显式声明后,pandas 不再推断这几列,订单号永远是字符串,不会出现00123变成123的情况。category适合城市、渠道这种取值重复度高的列,几十万行数据里能把内存压下来一大截,代价是新增类别时要先cat.add_categories。na_values把业务上各种「看起来是空」的写法统一映射成 NaN,否则""和"NULL"在后续统计里是两种东西。
参数说明:parse_dates收列名或列名列表,底层走dateutil解析,格式混杂时会慢。如果日期格式统一(比如全是2024-03-01),加date_format="%Y-%m-%d"能快好几倍。keep_default_na=True保留 pandas 内置的空值识别,不要关,关了NaN字符串就不会被当成空。
读完先看df.dtypes和df.shape,两行输出就是这份数据的体检报告。类型不对的列,进下一步之前先改对,别带着 object 类型的数值列去做聚合。
3.2 缺失值:先统计再决定 fill 还是 drop
新手最容易犯的错是先fillna(0)再说。金额列填 0 会让平均值失真,日期列填 0 直接报类型错误。正确顺序是先统计每列缺失比例,再按列的性质决定策略:数值列看业务含义决定填 0 还是填中位数,分类列填众数或单独开一个「未知」类,关键字段缺失的行直接丢。
# 每列缺失数量与占比,一张表看清 miss = pd.DataFrame({ "缺失数": df.isna().sum(), "缺失率": (df.isna().mean() * 100).round(2), }) miss = miss[miss["缺失数"] > 0].sort_values("缺失率", ascending=False) print(miss) # 数值列:用中位数填充,比均值抗极端值 if "金额" in df.columns: df["金额"] = df["金额"].fillna(df["金额"].median()) # 分类列:单独开一类,保留「缺过」这个信息 df["城市"] = df["城市"].cat.add_categories(["未知"]).fillna("未知") # 关键字段缺失的行丢弃,subset 指定判定列 df = df.dropna(subset=["订单号", "用户ID"]).reset_index(drop=True)逻辑说明:isna().mean()求的是布尔列的均值,数值上等于缺失比例,一行代码出占比。fillna对category类型有前提,新值必须先add_categories,否则抛TypeError: Cannot setitem on a Categorical with a new category,这是 category 列特有的坑。dropna(subset=...)只按指定列判空,不加subset会把任意一列有空的整行都删掉,通常删得太多。
参数说明:reset_index(drop=True)删行后把索引重置成连续整数,drop=True表示丢弃旧索引而不是新增一列。如果不重置,后面按位置切片会拿到一堆断号索引,iloc和loc混用极易出错。
填完再跑一次缺失统计,确认该填的都填上了。数值列填中位数这一步在业务上要谨慎:销量缺失填 0 合理,单价缺失填 0 就不合理,这种差别脚本判断不了,得看列名和单位决定。
3.3 类型兜底:to_numeric 的 errors 参数怎么选
从 Excel 或人工录入来的表,金额列基本不可能干净:有千分位逗号、有货币符号、有全角数字、还有几行写着「待确认」。直接astype(float)会在第一个脏值处抛ValueError,而且不告诉你是哪一行。
# 先做字符串层面的清洗,再转数值 if df["金额"].dtype == object: df["金额"] = ( df["金额"] .astype("string") .str.replace("¥", "", regex=False) .str.replace(",", "", regex=False) .str.replace(",", "", regex=False) # 全角逗号 .str.strip() ) # errors="coerce" 把转不了的值变成 NaN,而不是整列炸掉 df["金额"] = pd.to_numeric(df["金额"], errors="coerce") # 转换后必须检查坏值数量,NaN 突增说明清洗规则不够 bad = df["金额"].isna().sum() print(f"金额列转换后空值 {bad} 行,占比 {bad / len(df):.2%}") # 数量列同理,整数用 Int64 而不是 int,能保留空值 df["数量"] = pd.to_numeric(df["数量"], errors="coerce").astype("Int64")逻辑说明:errors="coerce"的作用是把无法解析的值降级为 NaN,保证整列转换不中断。这是双刃剑——不中断意味着错误被藏起来了,所以紧接着必须统计 NaN 数量,跟清洗前的缺失数对比。如果转换前缺失 20 行、转换后缺失 300 行,说明有 280 行是格式问题被吞掉的,规则漏了。
参数说明:str.replace的regex=False必须显式给,否则 pandas 走正则模式,"¥"这种字符在某些正则引擎里会被当元字符,替换结果不符合预期。Int64(大写 I)是 pandas 的可空整数类型,普通int64不允许有空值,遇到 NaN 会被强转成 float,之后sum()出来的数带.0,写进 Excel 很难看。
清洗规则要按数据来源不同而不同:从网页抓的金额可能是"1,234.00",从 POS 导的可能是以分为单位的整数,从手工表来的可能混着中文备注。这一步没有通用解,只能看几行样本再定规则。
3.4 去重与主键校验:subset 选错等于白去
drop_duplicates()不带参数是整行去重,只要两行有任何一列不同就不算重复。订单表这种场景要的是按业务主键去重,主键选错要么去不干净,要么误删。更麻烦的是,去重之后没人校验行数变化是否合理,多删几千行也发现不了。
before = len(df) # 按业务主键去重,keep 决定保留第一条还是最后一条 df = df.drop_duplicates(subset=["订单号"], keep="last") after = len(df) print(f"去重前 {before} 行,去重后 {after} 行,删除 {before - after} 行") # 主键唯一性校验,不通过直接抛异常,别让脏数据流到下游 assert df["订单号"].is_unique, "订单号仍存在重复,检查 keep 参数与清洗逻辑" # 复合主键场景:子订单号 + 商品ID dup = df.duplicated(subset=["订单号", "商品ID"], keep=False) print(f"复合主键重复行数:{dup.sum()}")逻辑说明:keep="last"的语义是同一主键保留最后出现的那条,适用于「后来的记录更新」这种业务。如果数据来源是按时间追加的,keep="last"就是取最新状态;如果来源不可靠,keep=False会把所有重复行标出来,先看一眼再决定删哪条。
参数说明:subset接受列名列表,列名不存在会抛KeyError。is_unique是布尔属性,配合assert用,调试阶段把问题拦在清洗环节;上线脚本里换成显式raise ValueError,因为assert在python -O下会被跳过。
去重之后建议把删除的行单独存一份,命名成dropped_*.csv放output/。这类中间产物在跟人核对数据时是救命的,对方问「为什么总数少了」,直接把这文件甩过去,比口头解释快得多。
4. 分组聚合与可视化:让结果表经得起抽查
清洗只保证数据可用,真正交付的是结果表和图。这部分最容易出的问题不是算错,而是「表和表之间对不上」——明细表总额和汇总表总额差几百块,谁也说不清是哪一步丢的。所以聚合和出表要围绕一件事设计:每个环节都有办法对账。
4.1 groupby 与 agg:as_index 与命名聚合
groupby默认把分组列变成索引,这个默认值坑过无数人。后面想按列名 merge 或者to_excel时,索引列不会自动成为普通列,输出到 Excel 里分组维度就丢了。
result = ( df.groupby(["城市", "渠道"], as_index=False) # 关键:保留为普通列 .agg( 订单数=("订单号", "nunique"), # 命名聚合,直接指定输出列名 总金额=("金额", "sum"), 客单价=("金额", "mean"), 最大单笔=("金额", "max"), ) ) # 派生指标单独算,别塞进 agg 里 result["客单价"] = (result["总金额"] / result["订单数"]).round(2) # 按总金额降序,取前 20 便于抽查 result = result.sort_values("总金额", ascending=False).reset_index(drop=True) print(result.head(20))逻辑说明:as_index=False让分组列保持为普通列,后续不用reset_index()。命名聚合(新列名=(源列, 函数))是 pandas 0.25 之后的标准写法,好处是输出列名可控,不用事后rename,也避免多层列索引。订单数用nunique而不是count,因为count数的是非空行数,同一个订单有多行明细时结果会翻倍,这种错在金额上看得不明显,在单量上差一倍,属于典型的静默错误。
参数说明:sort_values的ascending=False是降序;reset_index(drop=True)保证索引连续。聚合函数传字符串"sum"、"mean"、"nunique"都走 pandas 内置实现,比传 lambda 快一个量级。
聚合完第一件事是对账:result["总金额"].sum()应该等于清洗后df["金额"].sum(),误差只允许出现在浮点精度层面。不相等说明有行被分组丢掉了,常见原因是分组列本身有 NaN——groupby默认把 NaN 分组丢弃,加dropna=False能保留,但会产生一个空分组,要不要保留取决于业务。
4.2 pivot_table:多级列拍平与占比计算
透视表用来出「城市 × 渠道」这种交叉视图,比 groupby 直观。麻烦点在于多级列索引,输出到 Excel 会变成合并单元格,看着乱还不好二次处理。
pivot = pd.pivot_table( df, index="城市", columns="渠道", values="金额", aggfunc="sum", fill_value=0, # 空组合补 0,避免 NaN 传播 margins=True, # 加行列合计 margins_name="合计", ) # 多级列拍平成单层,方便写 Excel pivot.columns = [f"{c}" if isinstance(c, str) else "_".join(map(str, c)) for c in pivot.columns] pivot = pivot.reset_index() # 行占比:各城市占总额的比例 pivot["占比"] = (pivot["合计"] / pivot.loc[pivot["城市"] == "合计", "合计"].iloc[0]).round(4) print(pivot)逻辑说明:margins=True会自动加一行一列合计,margins_name控制合计行的标签。列拍平那段用列表推导处理两种情况:单层列是字符串直接用,多层列用下划线拼起来。fill_value=0很重要,交叉表里没发生的组合是 NaN,NaN 参与后续求和会污染整列结果。
参数说明:aggfunc除了"sum",还可以传列表["sum", "count"]一次出多个指标,但列层级会更深,拍平逻辑要跟着调整。占比计算里iloc[0]取合计行的值,前提是margins生成的行确实在最后一行,sort_values之后这个前提就不成立了,要么先算占比再排序,要么用loc按标签取。
透视表出完,抽样验算两三格。手工从原始 CSV 里筛一个城市一个渠道,加总金额,跟透视表对应格子比。这一步花两分钟,能省掉后面被追问半小时。
4.3 出表:ExcelWriter 多 sheet 与列宽
单个to_excel一次写一个 sheet,多份结果要分 sheet 就得用ExcelWriter上下文管理器。顺带把列宽调一下,否则打开 Excel 全是####,交付体验很差。
from openpyxl.utils import get_column_letter out_path = "output/analysis_result.xlsx" with pd.ExcelWriter(out_path, engine="openpyxl") as writer: df.to_excel(writer, sheet_name="清洗明细", index=False) result.to_excel(writer, sheet_name="城市渠道汇总", index=False) pivot.to_excel(writer, sheet_name="交叉透视", index=False) # 列宽自适应:按每列内容最大长度估算,上限 40 for sheet_name, frame in [("清洗明细", df), ("城市渠道汇总", result), ("交叉透视", pivot)]: ws = writer.sheets[sheet_name] for i, col in enumerate(frame.columns, start=1): width = max(frame[col].astype(str).str.len().max() if len(frame) else 0, len(str(col))) ws.column_dimensions[get_column_letter(i)].width = min(width + 2, 40)逻辑说明:ExcelWriter作为上下文管理器,退出时自动保存并关闭文件,不写with的写法容易忘记save()导致文件里什么都没有。列宽那段按内容最大字符长度估算,中文字符实际占两个字符宽度,所以估算值偏小是常态,+2是补偿,min(..., 40)防止某列出现超长文本把表撑变形。
参数说明:engine="openpyxl"必须显式指定,不指定时 pandas 会走xlsxwriter(如果装了)或报错。index=False不写行号,除非行号本身是业务字段,否则输出表里多一列无意义的数字。
提示:
to_excel对category类型的列会正常写成文本,但Int64可空整数列在某些 openpyxl 版本下会写成文本格式,打开后单元格左上角有绿色三角。交付前在 Excel 里抽查两列数字格式,别等对方说「你的数不能求和」才发现。
4.4 出图:中文不乱码的两处设置
matplotlib 默认字体不带中文,不设置的话图里所有中文标签变成方块。这个问题在 Linux 服务器上尤其常见,本地 Windows 跑没事、部署上去全是豆腐块。
import matplotlib matplotlib.use("Agg") # 无界面环境必须,避免起 GUI 后端报错 import matplotlib.pyplot as plt # 按优先级尝试系统中文字体,找不到会回落到下一个 plt.rcParams["font.sans-serif"] = ["SimHei", "Noto Sans CJK SC", "WenQuanYi Zen Hei"] plt.rcParams["axes.unicode_minus"] = False # 负号正常显示 fig, ax = plt.subplots(figsize=(10, 6)) top = result.head(10).iloc[::-1] # 倒序,让最大值在最上面 ax.barh(top["城市"] + "-" + top["渠道"].astype(str), top["总金额"]) ax.set_xlabel("总金额(元)") ax.set_title("城市渠道金额 Top10") plt.tight_layout() plt.savefig("output/top10.png", dpi=150, bbox_inches="tight") plt.close(fig)逻辑说明:font.sans-serif是一个候选列表,matplotlib 按顺序找系统里第一个存在的字体,所以把常见字体都列上能提高命中率。axes.unicode_minus=False解决负号显示成方块的问题,这个设置和字体问题经常一起出现。matplotlib.use("Agg")在无显示器的服务器上必须加,不加会在import pyplot时尝试连 X server 然后失败。
参数说明:dpi=150是输出分辨率,屏幕看 100 够用,要打印给 300。bbox_inches="tight"裁掉多余白边,避免标题被切。plt.close(fig)必须调,循环出多张图时不关会累积内存,跑到几十张就开始卡。
字体找不到时,Linux 上用fc-list :lang=zh列出系统现有的中文字体,把名字抄进列表;Windows 上直接写"SimHei"基本都有。这一步不用追求好看的字体,能显示中文就够。
4.5 输出自检:行数、合计、抽样三条对账
结果出完,交付前跑一遍自检,三条足够拦住绝大多数问题:
checks = { "明细行数与清洗后一致": len(df) == len(pd.read_csv("output/清洗明细.csv")) if False else True, "汇总金额与明细一致": abs(result["总金额"].sum() - df["金额"].sum()) < 0.01, "分组无空城市": result["城市"].isna().sum() == 0, } for name, ok in checks.items(): print(f"[{'PASS' if ok else 'FAIL'}] {name}") assert all(checks.values()), "自检未通过,先修数据再交付"逻辑说明:第一条留了占位写法,实际场景里应该读回输出的 CSV 比对行数,验证落盘过程没丢行。第二条是核心对账,浮点求和用差值比较而不是==。第三条检查分组维度是否完整,NaN 分组被丢弃是静默发生的,只能靠这种断言发现。
参数说明:浮点误差阈值取0.01,金额精确到分的场景够用;如果金额量级到百万,阈值可以放宽到0.1,但绝不能省掉这条检查。
自检不通过就别急着交付,回头查是哪一步改了行数。这套流程跑顺之后,换一份同结构的数据,改改列名和清洗规则就能复用。
5. 避坑与排查:五个把我卡到凌晨的现场
下面这五条都是实际跑这类脚本时反复出现的,每条按「现象—原因—解决」记,看到相似现象直接对照。
5.1 读取类:编码与引擎
现象一:UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd6 in position 0
原因:文件是 GBK 编码(Excel 中文版另存的 CSV 默认就是),脚本按 utf-8 读,第一个中文字符就炸。这类错误报的字节位置往往在文件开头,容易误以为是文件损坏。
解决:先用chardet探一下真实编码,再决定传什么:
import chardet with open("data/sales_raw.csv", "rb") as f: raw = f.read(100000) # 只读前 100KB,大文件不用全读 print(chardet.detect(raw)) # 输出示例 {'encoding': 'GB2312', 'confidence': 0.99} df = pd.read_csv("data/sales_raw.csv", encoding="gb18030")gb18030是 GBK 的超集,兼容性比gbk好,遇到生僻字不至于报错。别用errors="ignore"糊弄过去,那样会静默丢字符,后面做分组时城市名对不上,排查成本更高。
现象二:ImportError: Missing optional dependency 'openpyxl'
原因:read_excel读.xlsx需要引擎,pandas 不内置。虚拟环境里只装了 pandas,没装 openpyxl。
解决:在激活的虚拟环境里pip install openpyxl。装完确认python -c "import openpyxl"不报错。另一种情况是装了但装到了系统 Python 里,根源还是虚拟环境没激活,回去看提示符有没有(.venv)。
5.2 计算类:类型、空值与索引
现象三:KeyError: '订单号',但打印列名看起来一模一样
原因:CSV 带 BOM,第一列列名实际是\ufeff订单号。print(df.columns.tolist())输出时这个不可见字符不显示,肉眼看不出差别。
解决:读入时统一用encoding="utf-8-sig"。已经读进来的,用df.columns = df.columns.str.replace("\ufeff", "", regex=False)清一遍。这个坑的血泪经验是:凡是 Windows 上生成的 CSV,一律先按utf-8-sig读,读失败再退 gbk,别反过来。
现象四:分组汇总的金额比明细总额少,差额还不固定
原因:groupby默认dropna=True,分组列有空值的行被整行丢弃,且不报任何警告。差额大小取决于有多少行缺分组字段,所以每次不一样。
解决:先统计分组列的缺失情况df["城市"].isna().sum(),业务上该填「未知」就填,该丢就显式dropna(subset=["城市"])再分组,把丢弃动作写在代码里,别依赖默认行为。需要保留空分组时传dropna=False,但记得结果里会多一行 NaN 分组,出表前处理掉。
现象五:merge之后行数暴涨,几万行变几十万行
原因:关联键在右表不唯一。比如按用户ID关联用户表,用户表里同一个 ID 有两条历史记录,左表每一行都会匹配出两条。
解决:merge 前先验右表键唯一性:
assert users["用户ID"].is_unique, "用户表主键重复,先按更新时间去重再 merge" # 去重保留最新一条 users = users.sort_values("更新时间").drop_duplicates(subset=["用户ID"], keep="last") merged = df.merge(users, on="用户ID", how="left", validate="many_to_one")validate="many_to_one"是 merge 的自检参数,声明左表多对右表一,不满足直接抛MergeError,比事后数行数快得多。可选值还有"one_to_one"、"one_to_many"、"many_to_many",按实际关系声明。
5.3 输出类:覆盖、格式与目录
现象六:跑第二遍结果没变,或者 Excel 打不开提示文件损坏
原因有两种。一是脚本没有覆盖旧文件,而是往同一个ExcelWriter里重复写同名 sheet,pandas 会追加成Sheet1、Sheet11;二是上次运行异常退出,文件句柄没释放,留下半个损坏的 xlsx。
解决:每次运行前清空output/下的同名产物,或者给输出文件名加时间戳。ExcelWriter一定要用with语句,异常时也能保证关闭。如果文件仍打不开,先删掉再跑,别在损坏文件上继续写。
6. 进阶:把一次性作业改成可复用的 ETL 小骨架
跑通一遍之后,这个包最大的价值是当骨架用。改造成本不高,核心就三件事:把路径和参数从代码里抽出来、把清洗规则做成配置、在关键节点加自检。
先抽配置。所有可变的东西集中到一个字典,换数据只改这里:
| 配置项 | 作用 | 常见取值 |
|---|---|---|
input_path | 原始文件路径 | data/sales_raw.csv |
encoding | 读入编码 | utf-8-sig/gb18030 |
key_cols | 去重主键 | ["订单号"]或复合键 |
date_cols | 日期列 | ["下单时间"] |
num_cols | 需转数值的列 | ["金额", "数量"] |
group_cols | 分组维度 | ["城市", "渠道"] |
out_dir | 输出目录 | output |
然后用argparse接命令行,让同一份代码能跑不同批次的数据:
import argparse from pathlib import Path def build_parser(): p = argparse.ArgumentParser(description="通用数据处理入口") p.add_argument("--input", required=True, help="原始 CSV 路径") p.add_argument("--outdir", default="output", help="输出目录") p.add_argument("--encoding", default="utf-8-sig", help="读入编码") p.add_argument("--min-rows", type=int, default=100, help="清洗后最少行数,低于此值判定为异常并退出") return p if __name__ == "__main__": args = build_parser().parse_args() out = Path(args.outdir) out.mkdir(parents=True, exist_ok=True) # 自动建目录,省掉手工 mkdir df = load(args.input, args.encoding) df = clean(df, key_cols=["订单号"], num_cols=["金额", "数量"]) # 最低行数守卫:读错文件、编码错、分隔符错,都会让行数异常 if len(df) < args.min_rows: raise SystemExit(f"清洗后仅 {len(df)} 行,低于阈值 {args.min_rows},中止") save_all(df, out)--min-rows这个参数看着朴素,实际是最有效的一道闸。编码读错、分隔符传错、文件被截断,这几种故障的共同表现都是行数骤降,有了阈值守卫,脚本自己就停下来了,不用等到出图阶段才发现数据是空的。Path.mkdir(parents=True, exist_ok=True)顺带解决了输出目录不存在的老问题。
再进一步是把清洗规则也配置化,比如用一张rules.yaml描述每列怎么处理,代码只负责解释规则。这一步在这个包的量级上有点过度设计,但如果你手上的表有几十列、每列规则都不同,值得做。
最后说个验证习惯。我改完任何清洗逻辑,都固定做三件事:拿 100 行样本单独跑一遍看中间结果、跟原始文件手工对两三个格子的数、把output/下所有文件删掉从头跑一次确认无残留依赖。这三步加起来不到五分钟,省下的是交付后被反问「这个数怎么来的」的尴尬。从那以后我每次拆这类压缩包,都先把data/和output/分死、先跑一遍全流程再动逻辑,改一处验一处,不再一次性改五个地方然后对着报错猜是哪出的问题。希望帮到你。
本文还有配套的精品资源,点击获取