1. 项目概述:为什么一个十年老手还在反复重写 DataFrame 教程?
我带过三十多个数据科学项目,从金融风控模型到电商用户行为分析,从医疗影像元数据清洗到工业传感器时序对齐——所有项目里,92% 的时间花在数据准备上,而其中超过 70% 的卡点,都出在 DataFrame 这个看似最基础的结构上。不是不会用,而是“以为会了,结果线上崩了”。比如上周一个客户系统凌晨三点告警,查了一小时发现是df.loc[0] = [...]在多线程环境下意外覆盖了原始索引;再比如另一个项目上线前测试通过,生产环境却因df.drop(columns=['A'], inplace=True)后紧接着调用df['A']报 KeyError 而中断——这些都不是语法错误,而是对 DataFrame 内存模型、视图机制和索引语义的误判。
这篇教程不讲“DataFrame 是什么”,因为定义百度三秒就能搜到;它直击真实战场:当你面对一份来自 ERP 系统导出的 Excel(含合并单元格、空行、中文列名混数字)、一份 IoT 设备传来的 JSON 流(字段动态增减、嵌套深度不一)、或是一份爬虫抓取的 HTML 表格(跨行跨列、乱码、缺失值标记为“N/A”而非 NaN)时,如何用 DataFrame 稳、准、快地完成清洗、对齐、聚合、验证。它不回避那些文档里轻描淡写的“注意”二字背后的真实代价——比如.copy()深浅拷贝的内存爆炸风险,比如inplace=True在链式操作中失效的隐蔽陷阱,比如reset_index(drop=True)和reset_index(inplace=True)在函数封装时引发的不可复现 bug。
核心关键词早已融入日常:DataFrame 构建、索引语义、视图与副本、链式赋值、类型推断陷阱、字符串向量化处理、多级索引实战、内存优化技巧。这不是给刚学完 Python 基础的新手看的“入门指南”,而是给已经写过 5000 行 pandas 代码、却还在SettingWithCopyWarning警告里反复横跳的工程师准备的“避坑地图”。如果你曾因df['col'].str.replace()返回 None 而抓耳挠腮,或为pd.concat([df1, df2], ignore_index=True)后丢失原始时间戳精度而通宵调试——这篇就是为你写的。
2. DataFrame 的本质解构:它根本不是“二维表格”
2.1 三层骨架:数据、索引、列——但索引才是灵魂
很多教程把 DataFrame 比作 Excel 表格,这是最大的误导。Excel 的行号是纯序号,而 DataFrame 的index 是第一等公民,是数据的“身份证”。它不只标识位置,更承载业务语义:可以是时间戳(2023-01-01 09:30:00),可以是用户 ID(U10086),可以是地理编码(CN-BJ-0101),甚至可以是复合键((订单号, 商品SKU))。一旦 index 设计失误,后续所有groupby、merge、resample都会像在流沙上盖楼。
我见过最惨的案例:某物流系统将运单号设为字符串 index,但数据库导出时部分运单号末尾带空格("WD2023001 "),导致df.loc["WD2023001"]查不到数据,排查三天才发现是 index 的strip()没做。index 的洁净度,直接决定整个分析流程的鲁棒性。
提示:创建 DataFrame 时,永远优先显式指定
index=和columns=参数。哪怕用range(len(data)),也比依赖默认数值索引强——它强迫你思考“这一行代表什么实体”。
2.2 数据层:不是容器,而是“视图协议”的执行者
DataFrame 的data参数能接收 ndarray、dict、list、Series,但关键在于:它不无脑复制数据,而是根据输入类型决定是否创建视图(view)或副本(copy)。这是性能与安全的分水岭。
pd.DataFrame(np.array([[1,2],[3,4]]))→ 创建副本,修改原 ndarray 不影响 DataFramepd.DataFrame({'A': [1,2], 'B': [3,4]})→ 对每个 list 创建副本pd.DataFrame({'A': pd.Series([1,2]), 'B': pd.Series([3,4])})→ Series 默认是视图,修改 Series 值会同步到 DataFrame
实测对比:
# 场景1:ndarray 输入(安全但内存高) arr = np.array([[1,2],[3,4]]) df1 = pd.DataFrame(arr) arr[0,0] = 999 # 修改原数组 print(df1.iloc[0,0]) # 输出 1!未变,因为是副本 # 场景2:Series 输入(省内存但需警惕) s = pd.Series([1,2]) df2 = pd.DataFrame({'A': s}) s.iloc[0] = 999 # 修改Series print(df2.iloc[0,0]) # 输出 999!已同步,因为是视图经验法则:对原始数据有修改需求?一律用pd.DataFrame(...).copy()显式声明;对超大文件内存敏感?用pd.read_csv(..., dtype=...)预设类型,避免后期astype()触发全量复制。
2.3 列(Columns):动态字典 vs 静态结构体——类型混合的真相
DataFrame 列允许不同类型共存(int64,string,datetime64),这看似灵活,实则是性能杀手。pandas 为每列单独存储,但混合类型会强制使用objectdtype,丧失向量化计算优势。例如:
# 危险:混合类型列(object dtype) df_bad = pd.DataFrame({'id': [1,2,3], 'name': ['Alice', 'Bob', None]}) print(df_bad.dtypes) # id: int64, name: object → 字符串操作慢10倍+ # 正确:显式类型声明(nullable integer + string) df_good = pd.DataFrame({ 'id': pd.array([1,2,3], dtype="Int64"), # 支持None的整型 'name': pd.array(['Alice', 'Bob', None], dtype="string") # pandas 1.0+ 新dtype })注意:
pd.array(..., dtype="string")不是str,而是 pandas 专用字符串类型,支持str.contains()等向量化方法,且内存比object列节省 40%+。这是 2022 年后新项目的标配。
3. 构建 DataFrame:从“能跑”到“稳如磐石”的七种方式
3.1 从零构建:别碰pd.DataFrame()的裸调用
新手最爱pd.DataFrame([[1,2],[3,4]]),但这是最脆弱的方式——列名自动变成0,1,索引是0,1,类型推断可能出错(如[1, 'a']全转object)。生产环境必须显式控制三要素:数据、索引、列名。
# ✅ 推荐:字典 + 显式索引/列名(清晰、可控、类型友好) data_dict = { 'user_id': [1001, 1002, 1003], 'login_time': pd.to_datetime(['2023-01-01', '2023-01-02', '2023-01-03']), 'score': [85.5, 92.0, 78.3] } df = pd.DataFrame( data_dict, index=pd.Index(['A', 'B', 'C'], name='session_id'), # 自定义索引+命名 columns=['user_id', 'login_time', 'score'] # 显式列顺序 ) # ❌ 避免:裸二维列表(列名索引全靠猜) df_risky = pd.DataFrame([[1001, '2023-01-01', 85.5]]) # 列名是0,1,2,类型全object3.2 从 NumPy 数组:绕开 dtype 陷阱的黄金公式
NumPy 数组转 DataFrame 最易踩坑的是结构化数组(structured array)。很多人用np.array([(1,'a'),(2,'b')], dtype=[('id','i4'),('name','U10')]),但pd.DataFrame(structured_arr)会把整行当一个 object,无法拆解列。正确姿势是:
# ✅ 正确:用字段名解包成字典 structured_arr = np.array( [(1, 'Alice'), (2, 'Bob')], dtype=[('id', 'i4'), ('name', 'U10')] ) # 解包为字典,再转DataFrame df = pd.DataFrame({ 'id': structured_arr['id'], 'name': structured_arr['name'] }) # ✅ 更优:用 record array + 属性访问(一行解决) record_arr = structured_arr.view(np.recarray) df = pd.DataFrame({ 'id': record_arr.id, # 直接属性访问 'name': record_arr.name })3.3 从外部数据源:CSV/Excel 的“脏数据”预处理三板斧
真实数据源永远不干净。以下是我处理过 200+ 个不同来源 CSV 的标准化流程:
| 步骤 | 操作 | 为什么必须做 | 实操命令 |
|---|---|---|---|
| 1. 编码与分隔符探测 | 自动识别编码(utf-8-sig, gbk, latin-1)和分隔符(逗号、制表符、分号) | 中文 Windows 导出 CSV 常用 gbk,Linux 系统默认 utf-8,错配直接乱码 | chardet.detect(open(file,'rb').read(10000))+csv.Sniffer().sniff() |
| 2. 头部与空行清理 | 跳过前 N 行(标题行、说明行),删除全空行 | ERP 系统导出常带“报表名称:销售汇总”等冗余行 | pd.read_csv(file, skiprows=2, skip_blank_lines=True) |
| 3. 类型预设与缺失值标记 | 显式dtype={'id':'Int64', 'price':'float64'},na_values=['N/A', 'NULL', ''] | 避免objectdtype,统一缺失值标识 | pd.read_csv(file, dtype={'id':'Int64'}, na_values=['N/A']) |
血泪教训:某次处理银行流水 CSV,因未设na_values=['-'],所有-被当字符串,后续df['amount'].sum()返回0(字符串求和忽略非数字),损失数万元对账时间。
3.4 从 API/JSON:嵌套数据的扁平化艺术
API 返回的 JSON 常是嵌套结构({"user":{"id":1,"profile":{"name":"A"}}}),直接pd.json_normalize()会生成列名user.id,user.profile.name,但业务分析需要user_id,user_name。我的标准化函数:
def flatten_json_to_df(json_data, sep='_'): """将嵌套JSON扁平化,自定义分隔符并清理列名""" df = pd.json_normalize(json_data, sep=sep) # 清理列名:移除开头的sep,替换特殊字符为空格,首字母小写 df.columns = [col.lstrip(sep).replace('.', '_').replace(' ', '_').lower() for col in df.columns] return df # 示例:处理含嵌套地址的用户数据 users = [ {"id":1, "name":"Alice", "address":{"city":"Beijing", "zip":"100000"}}, {"id":2, "name":"Bob", "address":{"city":"Shanghai", "zip":"200000"}} ] df = flatten_json_to_df(users) # 列名:id, name, address_city, address_zip3.5 从数据库:SQL 查询的“懒加载”与类型映射
pd.read_sql()直接读全表是自杀行为。我的实践:
- 分页读取:
pd.read_sql("SELECT * FROM logs WHERE dt BETWEEN %s AND %s", con, params=(start, end)) - 类型映射:PostgreSQL 的
NUMERIC映射为float64,但业务要求精确小数,改用decimal.Decimal:from decimal import Decimal df = pd.read_sql(query, con, dtype={'amount': Decimal}) # 保持精度 - 时间分区:对超大日志表,强制
WHERE dt >= '2023-01-01',绝不SELECT *。
3.6 从其他 DataFrame:concat的四大禁忌
pd.concat()是拼接高频操作,但 80% 的性能问题源于错误用法:
| 禁忌 | 后果 | 正确做法 |
|---|---|---|
不设ignore_index=True | 索引重复([0,1,2,0,1,2]),后续loc[0]返回多行 | pd.concat([df1,df2], ignore_index=True) |
不设sort=False | 自动按列名排序,打乱业务列序(['A','C','B']→['A','B','C']) | pd.concat([df1,df2], sort=False) |
不预设keys | 无法追溯数据来源,调试时不知哪行来自哪个源 | pd.concat([df1,df2], keys=['source1','source2']) |
不设copy=False | 强制深拷贝,内存翻倍 | pd.concat([df1,df2], copy=False)(pandas 2.0+ 默认) |
3.7 从 Series:单列变多列的“升维”技巧
Series 转 DataFrame 常被忽视,但它是处理时间序列的关键:
# 场景:将每日销售额 Series 转为宽表(年-月为列,日为索引) daily_sales = pd.Series( [100, 120, 95, ...], index=pd.date_range('2023-01-01', periods=365, freq='D') ) # 升维:按年月分组,转为列 monthly_pivot = daily_sales.groupby([ daily_sales.index.year, daily_sales.index.month ]).sum().unstack(level=1) # level=1 → 月份为列 # 结果:索引=年,列=1-12,值=各月总和4. 索引操作:.loc、.iloc、.at、.iat的生死时速
4.1 四大索引器的本质区别:标签、位置、标量、速度
| 索引器 | 工作方式 | 适用场景 | 性能 | 关键警告 |
|---|---|---|---|---|
.loc[] | 基于标签:df.loc[2]找索引值为2的行 | 安全、可读性强,支持切片df.loc['2023':'2024'] | 中 | 标签不存在时报KeyError,需df.loc[df.index.isin([2])]容错 |
.iloc[] | 基于位置:df.iloc[2]找第 3 行(0起) | 处理位置逻辑(如“取前10行”),索引混乱时唯一可靠方式 | 快 | df.iloc[100]超界报IndexError,df.iloc[100:]返回空 |
.at[] | 标签标量:df.at[2, 'A']取单个值(最快) | 高频单值访问(循环中取值) | 最快 | 只能取单个值,不支持切片 |
.iat[] | 位置标量:df.iat[2, 0]取第3行第1列 | 与.at[]同级性能,位置明确时首选 | 最快 | 同.iloc[]超界规则 |
实测性能(10万行DataFrame):
# 取第50000行第0列 %timeit df.at[df.index[50000], df.columns[0]] # 120 ns %timeit df.iat[50000, 0] # 85 ns %timeit df.loc[df.index[50000], df.columns[0]] # 1.2 μs(慢10倍!)经验:在 for 循环中取值?死守
.iat[]或.at[];做条件筛选?用.loc[condition];做位置切片?用.iloc[start:end]。
4.2.loc的隐藏能力:布尔索引与函数式筛选
.loc不只是取值,更是数据过滤的中枢:
# ✅ 布尔索引(推荐):清晰、向量化、高效 mask = (df['age'] > 18) & (df['score'] >= 80) adult_high_scorers = df.loc[mask, ['name', 'score']] # ✅ 函数式筛选:用 lambda 处理复杂逻辑 df.loc[lambda x: x['price'].between(100, 500), 'category'] = 'mid_range' # ✅ 链式操作容错:避免 SettingWithCopyWarning # 错误:df[df['age']>18]['score'] = 100 → 可能警告且不生效 # 正确:df.loc[df['age']>18, 'score'] = 100 → 100% 生效4.3 多级索引(MultiIndex):电商订单分析的终极武器
当数据有天然层次(如订单 → 商品 → SKU),MultiIndex 是唯一选择:
# 构建:用元组作为索引 arrays = [ ['Order_001', 'Order_001', 'Order_002', 'Order_002'], ['Item_A', 'Item_B', 'Item_A', 'Item_C'] ] index = pd.MultiIndex.from_arrays(arrays, names=['order_id', 'item_id']) df = pd.DataFrame({ 'qty': [2, 1, 5, 3], 'price': [100, 200, 80, 150] }, index=index) # 查询:层级穿透,无需 merge df.loc[('Order_001', 'Item_A'), 'qty'] # 直接取值:2 df.xs('Order_001', level='order_id') # 取子集:Item_A, Item_B 两行 df.groupby('order_id')['price'].sum() # 按订单聚合:300, 230避坑:df.reset_index()会将 MultiIndex 展平为普通列,若要保留层级关系,用df.reset_index(drop=False)。
4.4 索引重置的魔鬼细节:drop、inplace、level的组合拳
reset_index()看似简单,但参数组合决定成败:
| 参数 | 作用 | 典型场景 | 错误示范 |
|---|---|---|---|
drop=True | 丢弃原索引,不新增列 | 索引无业务意义,只需纯序号 | df.reset_index(drop=True)→ 索引变0,1,2... |
drop=False | 保留原索引为新列 | 需要将索引值作为特征参与建模 | df.reset_index(drop=False)→ 新增列index |
inplace=True | 直接修改原对象(不推荐) | 内存极度紧张,且确定不再需要原索引 | df.reset_index(inplace=True)→ 链式操作断裂 |
level=0 | 仅重置指定层级(MultiIndex) | 只想重置外层订单ID,保留内层商品ID | df.reset_index(level=0) |
终极建议:永远返回新对象,避免inplace=True。
# ✅ 安全:函数式风格,可链式调用 df_clean = (df .dropna(subset=['price']) # 清洗 .reset_index(drop=True) # 重置 .assign(order_rank=lambda x: x.index + 1) # 新增排名列 )5. 列操作:添加、删除、重命名的原子化实践
5.1 添加列:.assign()是唯一值得信赖的方法
df['new_col'] = value简单,但存在两大隐患:
- 链式赋值警告:
df[df['flag']==1]['col'] = 100可能不生效 - 原地修改风险:在函数中修改
df,调用方数据被意外改变
.assign()是函数式编程的救星:
# ✅ 安全:返回新DataFrame,原df不变 df_new = df.assign( price_taxed=lambda x: x['price'] * 1.1, category=lambda x: x['price'].apply(lambda p: 'high' if p > 100 else 'low'), timestamp=pd.Timestamp.now() # 静态值 ) # ✅ 链式调用:清晰表达数据流 result = (df .assign(sales_usd=lambda x: x['sales_cny'] / 7.2) .assign(month=lambda x: x['date'].dt.month) .groupby('month')['sales_usd'].sum() )5.2 删除列:drop()的三个致命参数
df.drop(columns=['A','B'])是标准用法,但必须理解参数:
| 参数 | 作用 | 必须设置? | 示例 |
|---|---|---|---|
axis=1 | 指定删除列(axis=0删行) | ✅ 是 | df.drop(['A'], axis=1) |
inplace=False | 返回新对象(默认) | ✅ 推荐 | df_new = df.drop(['A'], axis=1) |
errors='ignore' | 列不存在时不报错 | ⚠️ 按需 | df.drop(['A','B'], axis=1, errors='ignore') |
绝对禁止:del df['A']—— 它破坏 DataFrame 封装,且无法链式调用。
5.3 重命名列:rename()的正则与函数式双杀
rename()不仅能字典映射,更能用正则批量处理:
# ✅ 正则重命名:清理列名中的空格和特殊字符 df = df.rename(columns=lambda x: re.sub(r'[\s\.\-\(\)]+', '_', x.strip()).lower()) # ✅ 函数式重命名:按业务规则转换 df = df.rename(columns={ 'user_id': 'uid', 'login_time': 'ts_login', 'total_amount': 'amt_total' }) # ✅ 批量前缀:为所有列加前缀(避免merge冲突) df_user = df_user.rename(columns=lambda x: f'user_{x}') df_order = df_order.rename(columns=lambda x: f'order_{x}') merged = pd.merge(df_user, df_order, left_on='user_uid', right_on='order_uid')5.4 列类型转换:astype()的五大雷区与替代方案
astype()是最常用也最危险的类型转换:
| 雷区 | 问题 | 安全替代 |
|---|---|---|
astype(int)遇 NaN | ValueError: Cannot convert non-finite values | astype('Int64')(pandas nullable int) |
astype(str)低效 | 创建 object 列,内存暴增 | astype('string')(pandas 1.0+) |
astype('category')无序 | 分类变量无序,groupby结果乱序 | astype(pd.CategoricalDtype(categories=['low','mid','high'], ordered=True)) |
astype('datetime64')时区丢失 | 本地时间转为 naive datetime | pd.to_datetime(series, utc=True) |
astype('bool')对 'yes/no' 失败 | 只认True/False/1/0 | map({'yes':True, 'no':False}) |
生产环境黄金模板:
# 安全类型转换函数 def safe_cast_df(df, type_map): """type_map: {'col1': 'Int64', 'col2': 'string', 'col3': 'datetime64[ns, UTC]'}""" for col, dtype in type_map.items(): if col not in df.columns: continue if dtype == 'Int64': df[col] = pd.to_numeric(df[col], errors='coerce').astype('Int64') elif dtype == 'string': df[col] = df[col].astype('string') elif 'datetime' in dtype: df[col] = pd.to_datetime(df[col], errors='coerce', utc=True) return df # 使用 df = safe_cast_df(df, { 'user_id': 'Int64', 'name': 'string', 'created_at': 'datetime64[ns, UTC]' })6. 字符串与数值格式化:从“能用”到“专业”的质变
6.1 字符串清洗:.str访问器的向量化魔法
.str方法是字符串处理的核心,但必须理解其向量化本质:
# ✅ 向量化:一行处理百万行,毫秒级 df['phone'] = df['phone'].str.replace(r'\D', '', regex=True) # 移除非数字 df['email'] = df['email'].str.lower().str.strip() # ❌ 避免:`apply(lambda x: x.strip())` —— 慢100倍,且不处理NaN # ✅ 正确处理NaN:`.str` 方法自动跳过NaN,无需fillna df['name'].str.upper() # NaN保持NaN,不报错 # ✅ 复杂清洗:用正则提取关键信息 df['order_id'] = df['ref_text'].str.extract(r'ORDER-(\d{6})') # 提取6位订单号 df['country'] = df['ip'].str.split('.').str[0].map({'192': 'CN', '10': 'US'}) # IP段映射6.2 数值格式化:round()、clip()、where()的业务语义
数值处理不是数学运算,而是业务规则落地:
# ✅ 价格四舍五入到分(金融级精度) df['price_rounded'] = (df['price_raw'] * 100).round().astype('Int64') / 100 # ✅ 截断异常值(业务规则:销量不能超10000) df['sales_clipped'] = df['sales'].clip(upper=10000) # ✅ 条件赋值(业务规则:VIP用户折扣率=15%,否则=5%) df['discount_rate'] = df['is_vip'].where(df['is_vip'], 0.05).where(~df['is_vip'], 0.15) # ✅ 安全除法(避免除零) df['conversion_rate'] = df['clicks'].div(df['impressions'].replace(0, np.nan))6.3 时间格式化:dt访问器的时序分析基石
时间列是数据分析的命脉,dt访问器提供全维度解析:
# ✅ 标准化:确保是datetime64 df['event_time'] = pd.to_datetime(df['event_time'], errors='coerce', utc=True) # ✅ 提取业务特征(无需字符串分割) df['hour_of_day'] = df['event_time'].dt.hour df['day_of_week'] = df['event_time'].dt.dayofweek # 0=周一 df['is_weekend'] = df['event_time'].dt.dayofweek.isin([5,6]) df['month_start'] = df['event_time'].dt.day <= 3 # 每月前3天 # ✅ 时间窗口聚合(电商大促分析) df.set_index('event_time', inplace=True) hourly_sales = df['revenue'].resample('1H').sum() # 每小时销售额 weekly_growth = df['revenue'].resample('1W').sum().pct_change() # 周环比6.4 缺失值处理:fillna()、interpolate()、dropna()的战略选择
缺失值不是技术问题,而是业务信号:
| 方法 | 适用场景 | 业务含义 | 命令示例 |
|---|---|---|---|
fillna(0) | 计数类字段(如点击量),0 表示“未发生” | “该用户当天未点击” | df['clicks'].fillna(0) |
fillna(method='ffill') | 时间序列,用前值填充(如股价) | “延续昨日状态” | df['stock_price'].fillna(method='ffill') |
interpolate() | 连续数值(温度、传感器读数),线性插值 | “平稳过渡” | df['temp'].interpolate(method='linear') |
dropna(how='all') | 删除全空行(数据采集故障) | “该记录无效” | df.dropna(how='all') |
dropna(thresh=3) | 至少3列有值才保留 | “最低信息完整性要求” | df.dropna(thresh=3) |
关键原则:永远先问“这个 NaN 代表什么业务含义?”,再选技术方案。填 0 和删掉,是完全不同的业务决策。
7. 高级操作:groupby、merge、pivot的实战心法
7.1groupby:从“分组求和”到“业务洞察引擎”
groupby是 pandas 的心脏,但 90% 的人只用到表面:
# ✅ 基础:多列分组 + 多函数聚合 result = df.groupby(['region', 'product_type']).agg({ 'revenue': ['sum', 'mean'], 'orders': 'count', 'avg_order_value': lambda x: x.sum() / x.count() }).round(2) # ✅ 高级:分组内排序 + 取TopN(每个地区销量Top3商品) top3_per_region = (df .sort_values(['region', 'revenue'], ascending=[True, False]) .groupby('region') .head(3) ) # ✅ 高级:分组内标准化(Z-score) df['revenue_zscore'] = df.groupby('region')['revenue'].transform( lambda x: (x - x.mean()) / x.std() )7.2merge:how参数的业务逻辑映射
merge的how参数不是技术选择,而是业务契约:
how | 业务场景 | 示例 | 风险 |
|---|---|---|---|
inner | “必须同时存在于双方的数据才有效” | 订单表 ∩ 用户表 → 只分析已注册用户的订单 | 丢失未注册游客订单 |
left | “以左表为主,右表信息为补充” | 订单表 ← 用户表 → 所有订单,用户信息缺失则为NaN | 右表字段全NaN需检查关联键 |
right | “以右表为主” | 用户表 → 订单表 → 所有用户,即使无订单 | 左表(订单)字段全NaN |
outer | “全集,双方数据都要” | 订单表 ∪ 用户表 → 所有用户和所有订单 | 产生大量NaN,需谨慎处理 |
黄金法则:永远用validate='one_to_many'等参数校验关联键质量:
# 确保 user_id 在 users 表中唯一,在 orders 表中可重复 merged = pd.merge( orders, users, on='user_id', how='left', validate='one_to_many' # 若users中user_id不唯一,报错 )7.3pivot与melt:宽表 ↔ 长表的业务驱动转换
宽表(列多)适合展示,长表(行多)适合分析。转换必须由业务驱动:
# ✅ 场景:销售数据宽表 → 按月分析(需长表) # 原始:user_id, Jan_Sales, Feb_Sales, Mar_Sales df_long = df.melt( id_vars=['user