再见 Pandas,新进王者 Polars 实战 CookBook 来了
这两年做 Python 数据处理,Polars 出现得越来越频繁。
一开始很多人注意到它,原因很简单:快。,同样是读 CSV、筛数据、做聚合,Polars 在不少场景下确实比 pandas 更快,内存占用也更克制。尤其数据从几十万行涨到几百万、几千万行以后,这种差别会越来越明显。
但真用一段时间以后,会发现 Polars 值得看的地方其实不只是性能。
它背后是一套和 pandas 不太一样的数据处理思路。表达式、严格的数据类型、lazy execution、查询优化,这些东西放到一起以后,你写的不再只是“一步一步操作 DataFrame”,而更像是在描述:我要什么数据、做什么变换,剩下的执行过程交给 Polars 自己优化。
这也是为什么 Polars 这几年越来越多地出现在数据分析、ETL、特征工程,甚至一些原来默认用 pandas 的 Python 项目里。
当然,如果只是看 API 文档,很容易又变成背函数:select 怎么写,filter 怎么写,group_by 又怎么写。过两天真正碰到 CSV 编码不对、字段类型混乱、脏数据、时间序列和 SQL,还是不知道该怎么串起来。
所以这篇我不准备按 API 一个个讲。
我们直接顺着几份真实数据走一遍:从 CSV 读取开始,看看怎么选列、过滤、聚合,怎么处理字符串、日期和脏数据,最后再把数据写进 SQL 数据库。
如果你之前用过 pandas,这一路会非常容易对照;如果刚开始接触 Polars,也能顺着实际的数据处理过程把它的基本思路串起来。
如果你只把 Polars 当成“更快的 pandas”,其实会漏掉它很好用的一大半。
我更愿意把 Polars 当成一套完整的数据处理工具:把文件读进来,检查类型和脏数据,再做选择、过滤、聚合,处理时间和字符串,最后还可以直接把结果写回 SQL 数据库。对已经会 Python 或 pandas 的人来说,顺着一条真实的数据处理流程学,往往比单独背 API 更容易上手,也更容易迁移到自己的任务里。
01读取 CSV:蒙特利尔自行车数据
数据背景
先从一个比较直观的数据开始:蒙特利尔 7 条自行车道在 2012 年每天的骑行人数。数据来自市政公开页面(velos-comptage:http://donnees.ville.montreal.qc.ca/dataset/velos-comptage),这里使用 data/bikes.csv。
先把需要的库导进来:
import polars as pl
import polars.selectors as cs
import seaborn as sbn
import matplotlib.pyplot as plt
# Make the graphs a bit prettier, and bigger
plt.style.use('ggplot')
plt.rcParams['figure.figsize'] = (15, 5)
print(pl.__version__)
输出:1.6.0
读取 CSV 文件
Polars 读 CSV 直接用 read_csv,默认会把逗号当作字段分隔符。先直接读一次看看:
broken_df = pl.read_csv('../data/bikes.csv', encoding = "ISO-8859-1")
broken_df.head(3)
结果一眼就能看出不对:整个文件被读成了一个字符串列。
shape: (3, 1)
┌─────────────────────────────────┐
│ Date;Berri 1;Brébeuf (données … │
│ --- │
│ str │
╞═════════════════════════════════╡
│ 01/01/2012;35;;0;38;51;26;10;1… │
│ 02/01/2012;83;;1;68;153;53;6;4… │
│ 03/01/2012;135;;2;104;248;89;3… │
└─────────────────────────────────┘
原因也很简单:这个文件真正的分隔符是分号 ;,不是逗号。把几个读取参数补上就行:
分隔符改成 ;编码改成 'latin1'(默认是'utf8')尝试自动解析日期列 用 schema_overrides显式指定两个“数据不可用”的空列是Int64
fixed_df = pl.read_csv('../data/bikes.csv',
separator=';',
encoding='latin1',
try_parse_dates=True,
schema_overrides={'Brébeuf (données non disponibles)': pl.Int64,
'St-Urbain (données non disponibles)': pl.Int64}
)
fixed_df.head(3)
再读一次,结构就正常了:
shape: (3, 10)
┌────────────┬─────────┬────────────┬────────────┬───┬─────────┬────────────┬─────────┬────────────┐
│ Date ┆ Berri 1 ┆ Brébeuf ┆ Côte-Saint ┆ … ┆ du Parc ┆ Pierre-Dup ┆ Rachel1 ┆ St-Urbain │
│ --- ┆ --- ┆ (données ┆ e-Catherin ┆ ┆ --- ┆ uy ┆ --- ┆ (données │
│ date ┆ i64 ┆ non ┆ e ┆ ┆ i64 ┆ --- ┆ i64 ┆ non │
│ ┆ ┆ disponibl… ┆ --- ┆ ┆ ┆ i64 ┆ ┆ disponi… │
│ ┆ ┆ --- ┆ i64 ┆ ┆ ┆ ┆ ┆ --- │
│ ┆ ┆ i64 ┆ ┆ ┆ ┆ ┆ ┆ i64 │
╞════════════╪═════════╪════════════╪════════════╪═══╪═════════╪════════════╪═════════╪════════════╡
│ 2012-01-01 ┆ 35 ┆ null ┆ 0 ┆ … ┆ 26 ┆ 10 ┆ 16 ┆ null │
│ 2012-01-02 ┆ 83 ┆ null ┆ 1 ┆ … ┆ 53 ┆ 6 ┆ 43 ┆ null │
│ 2012-01-03 ┆ 135 ┆ null ┆ 2 ┆ … ┆ 89 ┆ 3 ┆ 58 ┆ null │
└────────────┴─────────┴────────────┴────────────┴───┴─────────┴────────────┴─────────┴────────────┘
Date 已经被正确解析成 date 类型,后面做时间分析就顺了。这里也很能说明一个问题:读 CSV 往往不是写一行 read_csv 就结束,碰到真实数据,分隔符、编码、日期和字段类型都值得先看一眼。
选择一列
读进来以后得到的是 DataFrame。如果只想拿某一列,用法和从字典里取元素很像:
fixed_df['Berri 1']
输出是一个 Series,全年有 310 天:
shape: (310,)
Series: 'Berri 1' [i64]
[
35
83
135
144
197
…
2405
1582
844
966
2247
]
绘制单列
sbn.lineplot(fixed_df, x='Date', y='Berri 1')
走势很直观:一月、二月、三月骑行人数很低,四月开始明显上升,盛夏达到高位,十月以后再往下掉。
一次绘制所有路径
用 unpivot 把宽表熔成长表,再按 hue='trail' 分组一次画出全部列:
melt_df = fixed_df.unpivot(index='Date', variable_name='trail', value_name='distance')
with plt.rc_context({'figure.figsize': (15, 10)}):
sbn.lineplot(melt_df, x='Date', y='distance', hue='trail')
所有路径放到一张图里稍微有点挤,但整体趋势还是很清楚:至少在这份 2012 年样本里,各条路径的季节性变化基本同步。
把流程串起来
前面拆开讲了几个步骤,真正画 Berri 1 时,代码其实只有这么几行:
fixed_df = pl.read_csv('../data/bikes.csv', separator=';', encoding='latin1', try_parse_dates=True)
sbn.lineplot(fixed_df, x='Date', y='Berri 1')
这一部分真正值得记住的是:
read_csv不只是给一个路径。separator、encoding、try_parse_dates、schema_overrides这些参数,往往决定了后面的处理是不是顺利;再配合df['col']、unpivot和画图,已经能完成一轮很常见的数据查看。
02选择数据:寻找最常见的投诉类型
接着换一份更像真实业务数据的内容:纽约市 311 服务请求的子集,数据来源见 NYC Open Data。字段很多,用它来看 Polars 怎么做数据概览和选列正合适。
import polars as pl
import polars.selectors as cs
import seaborn as sbn
import matplotlib.pyplot as plt
plt.style.use('ggplot')
plt.rcParams['figure.figsize'] = (15, 5)
print(pl.__version__)
因为列里有混合类型,读的时候给 Incident Zip 指明类型,避免报错:
# because of mixed types we specify dtype to prevent any errors
complaints = pl.read_csv('../data/311-service-requests.csv', schema_overrides={'Incident Zip':pl.String})
这里需要显式指定
Incident Zip的类型,本身已经是一个信号:这列数据并不规整。先让它读进来,后面处理脏数据时再回来细看。
里面有什么?(概览)
面对一张 50 多列的大表,我一般不会急着写处理逻辑,先用 head 看几行,确认字段大概长什么样:
complaints.head()
输出 (5, 52)——5 行 52 列的大宽表(Unique Key、Created Date、Agency、坐标、Location 等)。
选择一列(Series)
取单列时,Polars 常用的写法有两种:
稳妥的函数式写法: df.get_column('col'),永远返回Series[]索引写法:df['col'],根据输入可能返回Series或DataFrame
single_column = complaints.get_column('Complaint Type') # can also use complaints['Complaint Type']
display(type(single_column))
display(single_column)
polars.series.series.Series
shape: (111_069,)Series: 'Complaint Type' [str]["Noise - Street/Sidewalk""Illegal Parking""Noise - Commercial""Noise - Vehicle""Rodent" …"Maintenance or Facility""Illegal Parking""Noise - Street/Sidewalk""Noise - Commercial""Blocked Driveway"]
Series 也有 head:
complaints['Complaint Type'].head()
选择多列(DataFrame)
如果只关心投诉类型和所在区,其余几十列都不想带着,可以直接用 select。它返回的始终是 DataFrame:
complaints.select('Complaint Type', 'Borough').head()
polars.selectors(这里别名 cs)提供了细粒度选列语法。例如:选 Created Date 列 + 所有名字里含 "School" 的列:
complaints.select('Created Date', cs.contains('School')).head()
最常见的投诉类型是什么
这种“某个类别各出现多少次”的问题,不需要自己写分组,直接 value_counts():
complaints['Complaint Type'].value_counts(sort=True)
shape: (165, 2)
┌─────────────────────────┬───────┐
│ Complaint Type ┆ count │
│ --- ┆ --- │
│ str ┆ u32 │
╞═════════════════════════╪═══════╡
│ HEATING ┆ 14200 │
│ GENERAL CONSTRUCTION ┆ 7471 │
│ Street Light Condition ┆ 7117 │
│ DOF Literature Request ┆ 5797 │
│ PLUMBING ┆ 5373 │
│ … ┆ … │
└─────────────────────────┴───────┘
只看 Top 10,用 top_k:
complaint_counts = complaints['Complaint Type'].value_counts().top_k(10, by='count')
complaint_counts
shape: (10, 2)
┌────────────────────────┬───────┐│ Complaint Type ┆ count ││ --- ┆ --- ││ str ┆ u32 │╞════════════════════════╪═══════╡│ HEATING ┆ 14200 ││ GENERAL CONSTRUCTION ┆ 7471 ││ Street Light Condition ┆ 7117 ││ DOF Literature Request ┆ 5797 ││ PLUMBING ┆ 5373 ││ PAINT - PLASTER ┆ 5149 ││ Blocked Driveway ┆ 4590 ││ NONCONST ┆ 3998 ││ Street Condition ┆ 3473 ││ Illegal Parking ┆ 3343 │└────────────────────────┴───────┘
再画成柱状图,差距会更直观:
plt.xticks(rotation=45)
sbn.barplot(complaint_counts, x='Complaint Type', y='count')
这一部分基本就是日常“先看、再选、再统计”的完整动作:
head()看数据,get_column/[]取单列,select取多列,value_counts+top_k找出最常见的类别。
03哪个区的噪声投诉最多(更多选择数据的技巧)
继续用纽约 311 数据,顺手做一个更接近真实分析的问题:哪个区的噪声投诉最多?
import polars as pl
import polars.selectors as cs
import seaborn as sbn
import matplotlib.pyplot as plt
plt.style.use('ggplot')
plt.rcParams['figure.figsize'] = (15, 5)
print(pl.__version__)
# because of mixed types we specify dtype to prevent any errors
complaints = pl.read_csv('../data/311-service-requests.csv', schema_overrides={'Incident Zip':pl.String})
只选噪声投诉
先看数据长什么样:
complaints.head()
要筛“噪声投诉”,就是 Complaint Type == "Noise - Street/Sidewalk" 的行。用 filter:
noise_complaints = complaints.filter(pl.col('Complaint Type') == "Noise - Street/Sidewalk")
noise_complaints.head(3)
这里顺便看一下 Polars 很核心的东西:表达式。pl.col('Complaint Type') == "Noise - Street/Sidewalk" 本身就是一个 Polars expression,计算后会得到一列布尔值:
pl.col('Complaint Type') == "Noise - Street/Sidewalk"
filter 接受任意能算出布尔 Series 的表达式。多个条件用 & 组合:
is_noise = pl.col('Complaint Type') == "Noise - Street/Sidewalk"
in_brooklyn = pl.col('Borough') == "BROOKLYN"
complaints.filter(is_noise & in_brooklyn).head()
如果只要少数几列:
complaints.filter(is_noise & in_brooklyn).select('Complaint Type', 'Borough', 'Created Date', 'Descriptor').head(10)
到底哪个区噪声最多?
对所有噪声投诉按区计数:
noise_complaints = complaints.filter(pl.col('Complaint Type') == "Noise - Street/Sidewalk")
noise_complaints['Borough'].value_counts(sort=True)
shape: (6, 2)
┌───────────────┬───────┐
│ Borough ┆ count │
│ --- ┆ --- │
│ str ┆ u32 │
╞═══════════════╪═══════╡
│ MANHATTAN ┆ 917 │
│ BROOKLYN ┆ 456 │
│ BRONX ┆ 292 │
│ QUEENS ┆ 226 │
│ STATEN ISLAND ┆ 36 │
│ Unspecified ┆ 1 │
└───────────────┴───────┘
只看绝对数量,Manhattan(曼哈顿)最高。但这里不能急着下结论:曼哈顿本身的投诉总量就可能更大。
更稳妥的看法,是比较每个区里噪声投诉占全部投诉的比例。这里用 group_by + agg + mean():
complaint_avgs = (
complaints
.group_by("Borough")
.agg(noise_complaint_avg=(pl.col('Complaint Type') == "Noise - Street/Sidewalk").mean())
.sort('noise_complaint_avg', descending=True)
)
complaint_avgs
shape: (6, 2)
┌───────────────┬─────────────────────┐
│ Borough ┆ noise_complaint_avg │
│ --- ┆ --- │
│ str ┆ f64 │
╞═══════════════╪═════════════════════╡
│ MANHATTAN ┆ 0.037755 │
│ BRONX ┆ 0.014833 │
│ BROOKLYN ┆ 0.013864 │
│ QUEENS ┆ 0.010143 │
│ STATEN ISLAND ┆ 0.007474 │
│ Unspecified ┆ 0.000141 │
└───────────────┴─────────────────────┘
换成比例以后,曼哈顿依然最高,约 3.7% 的投诉属于这一类噪声问题。这个结论就比单看绝对数量更有说服力。
sbn.barplot(complaint_avgs, x='Borough', y='noise_complaint_avg')
这里最值得带走的是表达式思路:
filter负责筛行,&可以组合条件,布尔表达式直接.mean()又能很自然地变成比例。很多 pandas 里要拆几步写的逻辑,在 Polars 里可以直接串起来。
04哪天骑车的人最多(group_by + aggregate)
再回到蒙特利尔自行车数据。我比较想知道的是,这条路线更偏“通勤”,还是更偏“周末休闲”?换成数据问题就是:周末骑得多,还是工作日更多?
import polars as pl
import polars.selectors as cs
import seaborn as sbn
import matplotlib.pyplot as plt
plt.style.use('ggplot')
plt.rcParams['figure.figsize'] = (15, 5)
print(pl.__version__)
加一列 weekday
先加载数据并画一眼 Berri 1:
bikes = pl.read_csv('../data/bikes.csv', separator=';', encoding='latin1', try_parse_dates=True)
bikes.plot.line(x='Date', y='Berri 1').properties(width='container')
sbn.lineplot(bikes, x='Date', y='Berri 1')
只留下 Berri 1 这条路径(Berri 是蒙特利尔一条重要的自行车道街道):
berri_bikes = bikes.select('Date', 'Berri 1')
berri_bikes.head()
要加一个“星期几”列。先从 Date 取星期几:
berri_bikes['Date']
这里还能看到一个小细节:这一年实际只有 310 天的数据,说明中间缺了一些日期。缺失原因这份数据里没有直接说明,所以先不额外推断。
取一年中的第几天:
berri_bikes['Date'].dt.ordinal_day()
shape: (310,)
Series: 'Date' [i16]
[
1
2
3
4
5
…
306
307
308
309
310
]
我需要的是星期几(1=周一,文档见 polars.Expr.dt.weekday):
berri_bikes['Date'].dt.weekday()
shape: (310,)
Series: 'Date' [i8]
[
7
1
2
3
4
…
4
5
6
7
1
]
把它加进 DataFrame 用 with_columns(和 select 很像,但保留原有列,重定义则覆盖):
berri_bikes = berri_bikes.with_columns(
weekday = pl.col('Date').dt.weekday()
)
berri_bikes.head()
按星期几累加骑行者
.group_by() 的思路和 SQL 里的分组聚合很接近(文档)。下面这段就是按 weekday 分组,再把同一天类型的 Berri 1 骑行人数累加起来:
weekday_counts = (
berri_bikes
.group_by('weekday')
.agg(pl.col('Berri 1').sum())
.sort('weekday')
)
weekday_counts
shape: (7, 2)
┌─────────┬─────────┐
│ weekday ┆ Berri 1 │
│ --- ┆ --- │
│ i8 ┆ i64 │
╞═════════╪═════════╡
│ 1 ┆ 134298 │
│ 2 ┆ 135305 │
│ 3 ┆ 152972 │
│ 4 ┆ 160131 │
│ 5 ┆ 141771 │
│ 6 ┆ 101578 │
│ 7 ┆ 99310 │
└─────────┴─────────┘
数字 1~7 看图不太直观,我给它补上星期名称。这里建一个很小的映射表,再 join 回去,同时保证 weekday 的类型都是 Int8:
days_df = pl.DataFrame(
data={
"weekday" :range(1, 8),
"weekday_name": ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
},
schema_overrides={'weekday':pl.Int8} # Make sure the type matches our weekday_counts dataframe
)
weekday_counts = weekday_counts.join(days_df, on='weekday')
weekday_counts
shape: (7, 3)
┌─────────┬─────────┬──────────────┐
│ weekday ┆ Berri 1 ┆ weekday_name │
│ --- ┆ --- ┆ --- │
│ i8 ┆ i64 ┆ str │
╞═════════╪═════════╪══════════════╡
│ 1 ┆ 134298 ┆ Monday │
│ 2 ┆ 135305 ┆ Tuesday │
│ 3 ┆ 152972 ┆ Wednesday │
│ 4 ┆ 160131 ┆ Thursday │
│ 5 ┆ 141771 ┆ Friday │
│ 6 ┆ 101578 ┆ Saturday │
│ 7 ┆ 99310 ┆ Sunday │
└─────────┴─────────┴──────────────┘
画出来:
sbn.barplot(weekday_counts, x='weekday_name', y='Berri 1')
结果比较明显:在 Berri 1 这条路线和这份 2012 年样本里,工作日累计骑行人数高于周末。它至少说明这条路线的工作日使用更集中,但只凭这一份样本,还不能直接外推到整个城市或其他年份。
合起来(用 lazy 模式)
前面的写法已经能跑,但 Polars 更有意思的一部分是 lazy 模式。把 read_csv 换成 scan_csv 后,前面的筛选、计算会先组成一套查询计划,直到 collect() 才真正执行和物化数据。
# scan_csv 先构建 lazy 查询,直到 collect 时才真正读取和计算。
bikes = pl.scan_csv('../data/bikes.csv', separator=';', encoding='latin1', try_parse_dates=True)
# 只保留 Berri 1 路径和星期几
berri_bikes = bikes.select('Berri 1', weekday = pl.col('Date').dt.weekday())
# 按星期几累加骑行人数
weekday_counts = (
berri_bikes.group_by('weekday')
.agg(pl.col('Berri 1').sum())
.join(days_df.lazy(), on='weekday')
.sort('weekday')
)
sbn.barplot(weekday_counts.collect(), x='weekday_name', y='Berri 1')
这一段把 Polars 的几块常用能力串起来了:
with_columns加派生列,dt.weekday()处理日期,group_by().agg()做聚合,再用join补充标签;数据量再大一些时,就可以顺手切到scan_csv的 lazy 流程。
05字符串操作:哪个月雪最大
日期处理完,再看一个经常被低估的部分:字符串列。这里用 data/weather_2012.csv 里的天气描述,顺便看看 Polars 怎么把文本条件和时间聚合放在一起。
import polars as pl
import polars.selectors as cs
import seaborn as sbn
import matplotlib.pyplot as plt
plt.style.use('ggplot')
plt.rcParams['figure.figsize'] = (15, 5)
print(pl.__version__)
weather_2012 = pl.read_csv('../data/weather_2012.csv', try_parse_dates=True)
weather_2012.head()
字符串操作 & 判断是否下雪
Weather 列记录的是每小时天气的文字描述。这里先定一个很简单、也很明确的规则:只要文字里包含 "Snow",就把这一小时记为下雪。
Polars 提供向量化的字符串函数。这行代码得到一个布尔列:
is_snowing = weather_2012['Weather'].str.contains('Snow')
# Not super useful
is_snowing.head()
shape: (10,)
Series: 'Weather' [bool]
[
false
false
false
false
false
false
false
false
false
false
]
全是布尔值不好看,画成 0/1:
# More useful!
is_snowing=is_snowing.cast(pl.Int8)
sbn.lineplot(is_snowing)
画成 0/1 以后就很好读了:冬季会出现连续的“1”,夏季基本没有。Polars 的字符串方法可以继续参考 Series string docs。
用重采样找雪最多的月份
先拿每个月的中位温度热个身。这里用 group_by_dynamic 按 '1mo' 做时间窗口聚合,不过它要求时间键先排好序:
# group_by_dynamic function requires the key to be pre-sorted
if not weather_2012['Date/Time'].is_sorted():
weather_2012 = weather_2012.sort('Date/Time')
weather_2012 = weather_2012.set_sorted('Date/Time')
temp_by_month = weather_2012.group_by_dynamic(
'Date/Time',
every='1mo'
).agg(pl.col('Temp (C)').median())
plt.xticks(rotation=45)
display(temp_by_month)
sbn.barplot(temp_by_month, x='Date/Time', y='Temp (C)')
shape: (12, 2)
┌─────────────────────┬──────────┐│ Date/Time ┆ Temp (C) ││ --- ┆ --- ││ datetime[μs] ┆ f64 │╞═════════════════════╪══════════╡│ 2012-01-01 00:00:00 ┆ -7.05 ││ 2012-02-01 00:00:00 ┆ -4.1 ││ 2012-03-01 00:00:00 ┆ 2.6 ││ 2012-04-01 00:00:00 ┆ 6.3 ││ 2012-05-01 00:00:00 ┆ 16.05 ││ … ┆ … ││ 2012-08-01 00:00:00 ┆ 22.2 ││ 2012-09-01 00:00:00 ┆ 16.1 ││ 2012-10-01 00:00:00 ┆ 11.3 ││ 2012-11-01 00:00:00 ┆ 1.05 ││ 2012-12-01 00:00:00 ┆ -2.85 │└─────────────────────┴──────────┘
结果也符合直觉:七月、八月最暖。
把“下雪”想成 0/1 而不是 True/False:
is_snowing.cast(pl.Int8).head(10)
然后按 is_snowing 的比例(mean)重采样成每月下雪比例:
snow_by_month = weather_2012.group_by_dynamic(
'Date/Time',
every='1mo'
).agg(
is_snowing=pl.col('Weather').str.contains('Snow').cast(pl.Int8).mean()
)
snow_by_month
shape: (12, 2)
┌─────────────────────┬────────────┐
│ Date/Time ┆ is_snowing │
│ --- ┆ --- │
│ datetime[μs] ┆ f64 │
╞═════════════════════╪════════════╡
│ 2012-01-01 00:00:00 ┆ 0.240591 │
│ 2012-02-01 00:00:00 ┆ 0.162356 │
│ 2012-03-01 00:00:00 ┆ 0.087366 │
│ 2012-04-01 00:00:00 ┆ 0.015278 │
│ 2012-05-01 00:00:00 ┆ 0.0 │
│ … ┆ … │
│ 2012-11-01 00:00:00 ┆ 0.038889 │
│ 2012-12-01 00:00:00 ┆ 0.251344 │
└─────────────────────┴────────────┘
plt.xticks(rotation=45)
sbn.barplot(snow_by_month, x='Date/Time', y='is_snowing')
按“天气描述里包含 Snow 的小时占比”来算,2012 年 12 月最高。这里要注意口径:它统计的是下雪描述出现的时间比例,不是降雪量,所以不能拿它直接判断积雪厚度或降雪强度。
把温度和降雪画在一起
把两个统计合到一个 DataFrame:
by_month = (
weather_2012
.group_by_dynamic(
pl.col('Date/Time').alias('Date'),
every='1mo')
.agg(
pl.col('Temp (C)').median(),
pl.col('Weather').str.contains('Snow').cast(pl.Int8).mean().alias('is_snowing'))
.sort('Date')
)
display(by_month)
两个子图上下并排:
fig, ax = plt.subplots(2, sharex=True)
sbn.barplot(by_month, x='Date', y='Temp (C)', ax=ax[0])
sbn.barplot(by_month, x='Date', y='is_snowing', ax=ax[1])
最后的散点/折线展示温度与降雪的关系:
sbn.lineplot(by_month, x='Temp (C)', y='is_snowing')
这一段的关键其实很简单:字符串条件先用
str.contains变成布尔值,再转成 0/1;一旦变成数值,就能自然地和group_by_dynamic、.mean()这些时间聚合方法接起来。
06清理脏数据
数据清洗里最麻烦的往往不是“怎么改”,而是先发现哪里不对。
还是用纽约 311 数据。它字段多、取值也杂,直接让 Polars 推断类型时就可能报错,因为某些列本身并不规整:
import polars as pl
import polars.selectors as cs
print(pl.__version__)
requests = pl.read_csv('../data/311-service-requests.csv')
可以强制让 Polars 尽量多读几行来推断类型(infer_schema_length=None),并看看 schema:
requests = pl.read_csv('../data/311-service-requests.csv', infer_schema_length=None)
display(requests.head())
display(requests.schema)
这时 Incident Zip 被解析成了 string。邮编用字符串保存本身不一定有问题,但既然前面已经出现过类型推断异常,就值得继续往下看。
怎么知道它脏不脏?
我自己排查脏数据时有个很朴素的习惯:字符串列先看 .unique(),数值列先看分布。 很多问题根本不需要复杂检测,取值摊开以后自己就会冒出来。
先看 Incident Zip:
requests['Incident Zip'].unique().sort()
shape: (251,)
Series: 'Incident Zip' [str]
[
null
"00000"
"000000"
"00083"
"02061"
…
"90010"
"92123"
"N/A"
"NA"
"NO CLUE"
]
这时候能看到几类明显异常:
有些被解析成字符串、有些是浮点 有 nan有两个特殊的 29616-0759、00083有 Polars 认不出来的 'N/A'、'NO CLUE'
修正 null 值与字符串/浮点混淆
第一步先处理最明显的假空值。用 null_values 把它们统一成真正的 null,同时把 Incident Zip 固定成字符串。如果看到旧代码里还在用 dtypes,现在可以改成 schema_overrides:
null_values = ['NO CLUE', 'N/A', '0', 'NA']
requests = pl.read_csv('../data/311-service-requests.csv', null_values=null_values, schema_overrides={'Incident Zip':pl.String})
requests['Incident Zip'].unique().sort()
破折号是怎么回事?
有些邮编带短横线(9 位邮编)。先数一下有多少:
rows_with_dashes = requests.filter(
pl.col('Incident Zip').str.contains('-')
)
print('number of zip codes with dashes: ', rows_with_dashes.height)
rows_with_dashes.head()
带短横线的不一定是脏数据。先看这些值,可以确认这里是正常的 9 位邮编。如果分析只需要前 5 位,再统一截断即可:
requests.filter(
pl.col('Incident Zip').str.contains('-')
)['Incident Zip'].unique()
确认格式没问题后,统一保留前 5 位:
requests = requests.with_columns(
pl.col('Incident Zip').str.slice(0, 5)
)
requests.filter(
pl.col('Incident Zip').str.contains('-')
)['Incident Zip'].unique()
接着看几个特别显眼的值。00083 看起来很像坏数据,但实际上它对应 Central Park(中央公园);真正更可疑的是 00000,所以先把相关记录单独查出来:
requests.filter(
pl.col('Incident Zip') == '00000'
)
这些记录没有保留这个邮编的价值,直接设为 null:
requests = requests.with_columns(
pl.when(pl.col('Incident Zip') == '00000').then(None).otherwise(pl.col('Incident Zip')).alias('Incident Zip')
)
requests.filter(
pl.col('Incident Zip') == '00000'
)
再看现在的 unique:
unique_zips = requests['Incident Zip'].unique().sort()
unique_zips
shape: (246,)
Series: 'Incident Zip' [str]
[
null
"00083"
"02061"
"06901"
"07020"
…
"70711"
"77056"
"77092"
"90010"
"92123"
]
到这里已经干净很多了,不过还有一个值很扎眼:77056 并不在纽约,而是在德州(Texas)。继续看这一行到底是什么:
requests.lazy().select(
'Incident Zip',
'Descriptor',
'City'
).filter(
pl.col('Incident Zip') == "77056"
).sort('Incident Zip').collect()
shape: (1, 3)
┌──────────────┬───────────────┬─────────┐
│ Incident Zip ┆ Descriptor ┆ City │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ str │
╞══════════════╪═══════════════╪═════════╡
│ 77056 ┆ Debt Not Owed ┆ HOUSTON │
└──────────────┴───────────────┴─────────┘
结果显示,样本里确实有一条来自休斯顿的请求。这也提醒了一点:真实数据里,单看一个字段很容易误判,邮编最好和 City 等字段一起核对。
requests['City'].str.to_uppercase().value_counts(sort=True)
shape: (101, 2)
┌────────────────┬───────┐
│ City ┆ count │
│ --- ┆ --- │
│ str ┆ u32 │
╞════════════════╪═══════╡
│ BROOKLYN ┆ 31662 │
│ NEW YORK ┆ 22664 │
│ BRONX ┆ 18438 │
│ null ┆ 12215 │
│ STATEN ISLAND ┆ 4766 │
│ … ┆ … │
└────────────────┴───────┘
City 有 12,215 个 null。细看会发现:很多空 City 的行,Incident Zip 也是空的:
requests.select(
'Incident Zip',
'Descriptor',
'City'
).filter(
pl.col('City').is_null()
).sort('Incident Zip')
shape: (12_215, 3)
┌──────────────┬──────────────────────────┬──────┐
│ Incident Zip ┆ Descriptor ┆ City │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ str │
╞══════════════╪══════════════════════════╪══════╡
│ null ┆ Street Light Out ┆ null │
│ null ┆ Street Light Out ┆ null │
│ … ┆ … ┆ … │
│ 10022 ┆ Driver Complaint ┆ null │
│ 11429 ┆ Dead Animal ┆ null │
└──────────────┴──────────────────────────┴──────┘
合起来:完整清洗流程
最后把前面的邮编清洗动作串成一条 lazy 流程:
null_values = ['NO CLUE', 'N/A', '0', 'NA']
requests = (
pl.scan_csv('../data/311-service-requests.csv', null_values=null_values, schema_overrides={'Incident Zip':pl.String})
.with_columns(pl.col('Incident Zip').str.slice(0, 5))
)
requests = (
requests
.with_columns(pl.when(pl.col('Incident Zip') == '00000').then(None).otherwise(pl.col('Incident Zip')).alias('Incident Zip'))
.filter(pl.col('Incident Zip').is_not_null())
.collect()
)
验证清洗结果:
requests['Incident Zip'].unique().sort()
清洗脏数据时,我反而最推荐先记住
.unique().sort()这种简单方法。先把异常值看出来,再用null_values、str.slice、when/then/otherwise去修,比一上来堆复杂规则更稳。数据量大时,同样可以放进 lazy 流程,最后一次collect()。
07处理时间戳(Unix 时间戳 → datetime)
再看一个很常见的时间问题:Unix 时间戳。
Polars 的列有明确的数据类型,所以整数就是整数,不会因为“看起来像时间”就自动变成 datetime。这里用 Ubuntu 的 popularity-contest 数据演示(字段说明见 popcon.ubuntu.com/README)。
import polars as pl
import polars.selectors as cs
print(pl.__version__)
解析 Unix 时间戳
读入这个空格分隔的文件,去掉最后一行(全空的行):
# Read it, and remove the last row
popcon = (
pl.read_csv('../data/popularity-contest',
separator=' ',
ignore_errors=True,
new_columns=['atime', 'ctime', 'package-name', 'mru-program', 'tag'])
.filter(~pl.all_horizontal(pl.all().is_null()))
)
popcon.shape
(2897, 5)
列分别是:访问时间、创建时间、包名、最近使用的程序、标签。现在 atime/ctime 被解析成了整数:
popcon.head()
shape: (5, 5)
┌────────────┬────────────┬──────────────┬─────────────────────────────────┬────────────────┐
│ atime ┆ ctime ┆ package-name ┆ mru-program ┆ tag │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ i64 ┆ i64 ┆ str ┆ str ┆ str │
╞════════════╪════════════╪══════════════╪═════════════════════════════════╪════════════════╡
│ 1387295797 ┆ 1367633260 ┆ perl-base ┆ /usr/bin/perl ┆ null │
│ … ┆ … ┆ … ┆ … ┆ … │
└────────────┴────────────┴──────────────┴─────────────────────────────────┴────────────────┘
这种 Unix 秒级时间戳,用 from_epoch 就能直接转成真正的 datetime,time_unit='s' 也是默认值:
popcon = popcon.with_columns(
pl.from_epoch('atime', time_unit='s'),
pl.from_epoch('ctime') #time_unit='s' is default
)
先看一下 dtype,确认已经变成 pl.Datetime:
popcon['atime'].dtype
再看前几行,时间已经变成正常可读的格式:
popcon.head()
shape: (5, 5)
┌─────────────────────┬─────────────────────┬──────────────┬──────────────────────┬────────────────┐
│ atime ┆ ctime ┆ package-name ┆ mru-program ┆ tag │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ datetime[μs] ┆ datetime[μs] ┆ str ┆ str ┆ str │
╞═════════════════════╪═════════════════════╪══════════════╪══════════════════════╪════════════════╡
│ 2013-12-17 15:56:37 ┆ 2013-05-04 02:07:40 ┆ perl-base ┆ /usr/bin/perl ┆ null │
│ 2013-12-17 15:56:36 ┆ 2012-12-01 14:01:20 ┆ login ┆ /bin/su ┆ null │
│ … ┆ … ┆ … ┆ … ┆ … │
└─────────────────────┴─────────────────────┴──────────────┴──────────────────────┴────────────────┘
接下来顺手做个筛选:找出非库类的包。先去掉时间戳为 0、也就是落在 Unix epoch 起点上的记录:
print("before filter")
display(popcon.bottom_k(3, by='atime'))
popcon = popcon.filter(
pl.col('atime') > pl.datetime(1970, 1, 1)
)
print("after filter")
display(popcon.bottom_k(3, by='atime'))
然后过滤掉包名里含 'lib' 的,按 ctime 取 Top 10:
nonlibraries = popcon.filter(
~pl.col('package-name').str.contains('lib')
)
nonlibraries.top_k(10, by='ctime')
Unix 时间戳这一块没太多花活:
pl.from_epoch负责转时间,pl.datetime(...)可以直接构造时间条件,再配合~、top_k、bottom_k做过滤和极值查询。
08从 SQL 数据库读取数据
前面一直在和 CSV 打交道,但真实项目里,数据更多时候还是躺在数据库里。Polars 本身也提供了数据库 I/O(见 I/O docs),这里用 SQLite 走一遍最基本的读写。
import polars as pl
from pathlib import Path
import sqlite3
print(pl.__version__)
从 SQL 数据库读取
读取时最直接的是 pl.read_database_uri:给它一段查询语句和一个连接 URI。换数据库时主要就是换连接 URI,这里先用 SQLite 演示:
read_db_path = Path('../data/weather_2012.sqlite').absolute()
read_uri = f"sqlite:////{read_db_path}"
df = pl.read_database_uri("SELECT * from weather_2012 LIMIT 3", read_uri)
df
shape: (3, 3)
┌─────┬─────────────────────┬──────┐
│ id ┆ date_time ┆ temp │
│ --- ┆ --- ┆ --- │
│ i64 ┆ datetime[ns] ┆ f64 │
╞═════╪═════════════════════╪══════╡
│ 1 ┆ 2012-01-01 00:00:00 ┆ -1.8 │
│ 2 ┆ 2012-01-01 01:00:00 ┆ -1.8 │
│ 3 ┆ 2012-01-01 02:00:00 ┆ -1.8 │
└─────┴─────────────────────┴──────┘
查询结果回来以后就是正常的 Polars DataFrame,SQL 字段名也会直接变成列名。
写回 SQLite 数据库
反过来写数据库也很直接,write_database 可以把一个 DataFrame 写成数据库表。这里把 2012 年天气数据写进 SQLite:
weather_df = pl.read_csv('../data/weather_2012.csv')
write_db_path = Path('../data/test_db.sqlite').absolute()
write_uri = f"sqlite:////{write_db_path}"
with sqlite3.connect(write_db_path) as con:
con.execute("DROP TABLE IF EXISTS weather_2012")
weather_df.write_database("weather_2012", write_uri)
输出行数:8784(全年逐小时)。
写完以后再读一次,确认数据已经正常落库:
df = pl.read_database_uri("SELECT * from weather_2012 LIMIT 3", write_uri)
df
shape: (3, 8)
┌────────────┬──────────┬─────────────┬────────┬──────────┬───────────┬───────────┬───────────┐
│ Date/Time ┆ Temp (C) ┆ Dew Point ┆ Rel Hum┆ Wind Spd ┆ Visibilit ┆ Stn Press ┆ Weather │
│ --- ┆ --- ┆ Temp (C) ┆ (%) ┆ (km/h) ┆ y (km) ┆ (kPa) ┆ --- │
│ str ┆ f64 ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ str │
│ ┆ ┆ f64 ┆ i64 ┆ i64 ┆ f64 ┆ f64 ┆ │
╞════════════╪══════════╪═════════════╪════════╪══════════╪═══════════╪═══════════╪═══════════╡
│ 2012-01-01 ┆ -1.8 ┆ -3.9 ┆ 86 ┆ 4 ┆ 8.0 ┆ 101.24 ┆ Fog │
│ 00:00:00 ┆ ┆ ┆ ┆ ┆ ┆ ┆ │
│ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … │
└────────────┴──────────┴─────────────┴────────┴──────────┴───────────┴───────────┴───────────┘
进数据库以后,就可以继续直接写 SQL。对本来就熟悉 SQL 的人来说,这种衔接很自然。比如按 Weather 排序取前 3 条:
df = pl.read_database_uri("SELECT * from weather_2012 ORDER BY Weather LIMIT 3", write_uri)
df
shape: (3, 8)
┌─────────────┬──────────┬─────────────┬─────────┬─────────────┬────────────┬────────────┬─────────┐
│ Date/Time ┆ Temp (C) ┆ Dew Point ┆ Rel Hum ┆ Wind Spd ┆ Visibility ┆ Stn Press ┆ Weather │
│ --- ┆ --- ┆ Temp (C) ┆ (%) ┆ (km/h) ┆ (km) ┆ (kPa) ┆ --- │
│ str ┆ f64 ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ str │
╞═════════════╪══════════╪═════════════╪═════════╪═════════════╪════════════╪════════════╪═════════╡
│ 2012-01-03 ┆ -16.9 ┆ -24.8 ┆ 50 ┆ 24 ┆ 25.0 ┆ 101.74 ┆ Clear │
│ 19:00:00 ┆ ┆ ┆ ┆ ┆ ┆ ┆ │
│ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … │
└─────────────┴──────────┴─────────────┴─────────┴─────────────┴────────────┴────────────┴─────────┘
连接其他类型的数据库
换成 MySQL 或 PostgreSQL,调用方式基本不变,主要就是连接 URI 不同:
# MySQL
pl.read_database_uri("select * from MY_TABLE", "mysql://username:password@server:port/database")
# PostgreSQL
pl.read_database_uri("select * from MY_TABLE", "postgresql://username:password@server:port/database")
对数据库这部分,记住两个入口就够用了:
read_database_uri(query, uri)负责读,write_database(table, uri)负责写。需要复杂查询时继续交给 SQL,本地处理再交给 Polars,两边可以很自然地接起来。
09写在最后
一路跑下来,Polars 日常数据处理其实就是一条很清楚的链路:
读进来: read_csv(含separator/encoding/try_parse_dates/schema_overrides/null_values),或scan_csv走 lazy,或read_database_uri走 SQL。看清楚: head()、.unique().sort()、.schema——先判断数据干不干净。选: df['col']/get_column取一列,select取多列,selectors cs.contains按名字选。筛: filter+ 表达式(==、str.contains),可用&组合,~取反。改: with_columns加列(dt.weekday()、from_epoch、str.slice),when/then/otherwise条件替换。聚合: group_by().agg(...),group_by_dynamic(every='1mo')按时间重采样,value_counts+top_k。变长: unpivot宽转长,方便一次画出多条线。看: seaborn + matplotlib一条龙出图。存: write_database落库;跨引擎只需换 URI。