日本大学数据#

本章以日本大学的数据为例,依次演示 Polars 的几项基本操作:读取文件、列的增删与转换、行过滤、分组聚合,以及画图。这些操作如果脱离数据逐条讲解,容易显得零散;放到一份真实数据上连贯地操作一遍,各步骤之间的衔接会更清楚。这份数据包含日文列名、缺失值,以及需要转换类型的字段,恰好覆盖了实际工作中最常见的几类问题。

导入语句按惯例写成 import polars as pl,后续代码都使用 pl 这个别名;随后读取 CSV 文件,并显示前两行。

读取文件#

import polars as pl
import hvplot.polars
df = pl.read_csv('data/japanese_universities.csv')
df.head(2)
shape: (2, 22)
codenamename_jptypetype_jpaddresspostal_codephonestatestate_jplatitudelongitudefoundfaculty_countdepartment_counthas_gradhas_remotereview_ratingreview_countdifficulty_SDdifficulty_rank
i64strstrstrstrstrstrstrstrstrstrf64f64stri64i64boolboolf64f64f64str
0"F101110100010""Hokkaido University""北海道大学""National""国立""北海道札幌市北区北8条西5丁目""060-0808""011-716-2111""Hokkai Do""北海道"43.070446141.347153"1876-08"3378truefalse4.161389.060.4"A"
1"F101110100029""Hokkaido University of Educati…"北海道教育大学""National""国立""北海道札幌市北区あいの里5条3-1-3""002-8501""011-778-0206""Hokkai Do""北海道"43.170498141.393753"1943-04"38truefalse3.79544.047.1"D"

columns 给出所有列名,顺序与 CSV 文件中的一致。

列的信息#

df.columns
['',
 'code',
 'name',
 'name_jp',
 'type',
 'type_jp',
 'address',
 'postal_code',
 'phone',
 'state',
 'state_jp',
 'latitude',
 'longitude',
 'found',
 'faculty_count',
 'department_count',
 'has_grad',
 'has_remote',
 'review_rating',
 'review_count',
 'difficulty_SD',
 'difficulty_rank']

列名与数据类型的对应关系可以用 schema 查看。它返回一个字典,键表示列名,值表示数据类型。由于 dtypes 只按列的顺序给出类型,名称与类型需要自行对照;schema 把两者放在一起,阅读时不必再逐项对齐,这也是它更适合检查列信息的原因。

df.schema
Schema([('', Int64),
        ('code', String),
        ('name', String),
        ('name_jp', String),
        ('type', String),
        ('type_jp', String),
        ('address', String),
        ('postal_code', String),
        ('phone', String),
        ('state', String),
        ('state_jp', String),
        ('latitude', Float64),
        ('longitude', Float64),
        ('found', String),
        ('faculty_count', Int64),
        ('department_count', Int64),
        ('has_grad', Boolean),
        ('has_remote', Boolean),
        ('review_rating', Float64),
        ('review_count', Float64),
        ('difficulty_SD', Float64),
        ('difficulty_rank', String)])

列操作#

不需要的列可以用 drop() 删除;它和 Polars 的大多数方法一样返回新表,原表保持不变,因此结果需要用一个变量保存。

df2 = df.drop('', 'code', 'name', 'type', 'state', 'postal_code', 'phone')
df2.head(2)
shape: (2, 15)
name_jptype_jpaddressstate_jplatitudelongitudefoundfaculty_countdepartment_counthas_gradhas_remotereview_ratingreview_countdifficulty_SDdifficulty_rank
strstrstrstrf64f64stri64i64boolboolf64f64f64str
"北海道大学""国立""北海道札幌市北区北8条西5丁目""北海道"43.070446141.347153"1876-08"3378truefalse4.161389.060.4"A"
"北海道教育大学""国立""北海道札幌市北区あいの里5条3-1-3""北海道"43.170498141.393753"1943-04"38truefalse3.79544.047.1"D"

新增列或转换已有列用 with_columns(),它返回的同样是一张新表。下面的代码对 df2 做两种转换,结果保存为 df3:

  • found:由字符串转换为日期型,YYYY-MM 形式的文本变成可以比较大小的日期值。

  • type_jp 和 difficulty_rank:由字符串转换为类别型。类别型不会重复存储相同的字符串,因而更节省内存,分组时也不必反复比较字符串。

传给 with_columns() 的不是计算结果,而是“表示计算的表达式”,这与 Pandas 的赋值写法差别很大。关于表达式的细节,可以参见表达式一章。

df3 = df2.with_columns(
    pl.col('found').str.to_date("%Y-%m"),
    pl.col('type_jp', 'difficulty_rank').cast(pl.Categorical)
)

转换后的列可以用 select() 单独取出,以确认结果是否正确。与其他 Polars 方法一样,它返回新表,df3 本身不会被改动。

df3.select('name_jp', 'found', 'type_jp', 'difficulty_rank').head()
shape: (5, 4)
name_jpfoundtype_jpdifficulty_rank
strdatecatcat
"北海道大学"1876-08-01"国立""A"
"北海道教育大学"1943-04-01"国立""D"
"室蘭工業大学"1897-05-01"国立""F"
"小樽商科大学"1910-03-01"国立""C"
"帯広畜産大学"1941-04-01"国立""B"

小技巧

select() 与 with_columns() 都用于列操作,区别只在输出:前者只保留选中的列,其余列全部丢弃;后者把新算出的列并入原表,未被操作的列原样保留。因此,需要换一张表时用前者,需要保留原表并追加列时用后者。

行过滤#

filter() 只返回满足条件的行,原表保持不变。下面的代码筛选偏差值(difficulty_SD)大于 65 的行,这些行对应偏差值较高的学校。

df3.filter(
    pl.col('difficulty_SD') > 65
)
shape: (8, 15)
name_jptype_jpaddressstate_jplatitudelongitudefoundfaculty_countdepartment_counthas_gradhas_remotereview_ratingreview_countdifficulty_SDdifficulty_rank
strcatstrstrf64f64datei64i64boolboolf64f64f64cat
"東京大学""国立""東京都文京区本郷7-3-1""東京都"35.714146139.7632141877-04-0125132truefalse4.342206.070.5"S"
"一橋大学""国立""東京都国立市中2-1""東京都"35.694389139.4436491920-04-011015truefalse4.26432.067.9"S"
"京都大学""国立""京都府京都市左京区吉田本町""京都府"35.026962135.7819671886-04-012894truefalse4.21434.065.6"S"
"国際教養大学""公立""秋田県秋田市雄和椿川字奥椿岱""秋田県"39.62706140.1811072004-04-0124truefalse4.18100.069.7"S"
"慶應義塾大学""私立""東京都港区三田2-15-45""東京都"35.649147139.7428281890-01-012859truetrue4.192682.066.3"S"
"日本医科大学""私立""東京都文京区千駄木1-1-5""東京都"35.721233139.7584531904-04-0122truefalse3.9931.070.0"S"
"早稲田大学""私立""東京都新宿区戸塚町1-104""東京都"35.710083139.7222441902-10-013594truetrue4.144280.065.7"S"
"国際基督教大学""私立""東京都三鷹市大沢3-10-2""東京都"35.686474139.5274811953-03-0126truefalse4.43206.068.0"S"

多个条件可以用 &(AND)、|(OR)、~(NOT)组合;每个条件都要用括号括起来,否则运算符的优先级会使表达式的含义与预期不符。下面的代码筛选位于大阪、且偏差值高于 55 的大学。

df3.filter(
    (pl.col('difficulty_SD') > 55) & (pl.col('state_jp').str.starts_with("大阪"))
)
shape: (3, 15)
name_jptype_jpaddressstate_jplatitudelongitudefoundfaculty_countdepartment_counthas_gradhas_remotereview_ratingreview_countdifficulty_SDdifficulty_rank
strcatstrstrf64f64datei64i64boolboolf64f64f64cat
"大阪大学""国立""大阪府吹田市山田丘1-1""大阪府"34.819496135.5211641917-04-012675truefalse4.121840.061.3"A"
"大阪医科薬科大学""私立""大阪府高槻市大学町2-7""大阪府"34.85186135.6244051904-05-0169truefalse4.119.057.0"B"
"関西大学""私立""大阪府吹田市山手町3-3-35""大阪府"34.769669135.5101621904-01-012944truefalse3.952661.055.6"B"

所谓方法链,是指把多个方法依次写在同一条语句中的写法。由于每一步都返回新的数据表,整个预处理流程可以写成一串连续的方法调用,从上往下读就是执行顺序,不必为中间结果逐一命名。

这段代码对 df3 依次做:

  • filter():只保留 type_jp 为“国立”的行。

  • sort():按偏差值降序排列。

  • drop_nulls():去掉偏差值为 NULL 的行。

  • select():只保留大学名和偏差值。

  • head():取前 5 行。

(df3
.filter(pl.col.type_jp == '国立')
.sort('difficulty_SD', descending=True)
.drop_nulls('difficulty_SD')
.select('name_jp', 'difficulty_SD')
.head(5)
)
shape: (5, 2)
name_jpdifficulty_SD
strf64
"東京大学"70.5
"一橋大学"67.9
"京都大学"65.6
"東京工業大学"65.0
"浜松医科大学"64.4

分组处理#

先查看 type_jp 的取值,结果只有国立、公立、私立三种,说明这一列适合作为分组键。

df3.select(pl.col('type_jp').unique())
shape: (3, 1)
type_jp
cat
"国立"
"私立"
"公立"

按分类列聚合时,需要 group_by() 与 agg() 配合使用:前者决定数据按什么分组,后者决定每组计算哪些统计量。

  • group_by():按指定列的值把数据分组。

  • agg():对每组做聚合,例如计数、求和、求平均等。

这个例子用 pl.len() 统计每个分组的行数,这也是分组聚合最简单的用法。

df3.group_by('type_jp').agg(pl.len())
shape: (3, 2)
type_jplen
catu32
"私立"626
"国立"86
"公立"101

除了计数,偏差值的最小值、中位数、平均值和最大值也可以在同一个 agg() 中一次算出。这样不必反复聚合再合并结果,输出列的名称则通过关键字参数指定。

(df3
.group_by('type_jp')
.agg(
    count = pl.len(),
    min = pl.col('difficulty_SD').min(),
    median = pl.col('difficulty_SD').median(),
    mean = pl.col('difficulty_SD').mean(),
    max = pl.col('difficulty_SD').max(),
)
)
shape: (3, 6)
type_jpcountminmedianmeanmax
catu32f64f64f64f64
"私立"62635.038.540.80103470.0
"公立"10140.050.050.04239169.7
"国立"8635.751.752.91707370.5

画图#

Polars 本身不提供画图功能,需要借助外部库。安装 hvplot 之后,可以直接通过 .hvplot 生成交互式图表,不必先转换成 Pandas 的 DataFrame。要把点叠加到地图上,还需要安装 geoviews,由它提供地理坐标与投影支持。

conda install -c conda-forge hvplot geoviews

柱状图#

下面的代码按都道府县统计大学的数量,从多到少排序,再画成柱状图。画图方法都由 .hvplot 提供,返回的是一个可交互的图对象,而不是静态图片。

  • 用 x 和 y 指定轴标签。

  • rot=90 让 X 轴标签竖排,避免都道府县名相互重叠。

  • frame_width=900 把图宽设为 900 像素。

x_label = "都道府县"
y_label = "大学数量"
(df3
.group_by(pl.col("state_jp").alias(x_label))
.agg(
    pl.len().alias(y_label)
)
.sort(y_label, descending=True)
.hvplot.bar(
    x=x_label, y=y_label,
    rot=90, frame_width=900
)
)

堆叠柱状图#

仅看总数并不能反映大学的构成。总数相同的都道府县,可能以国立大学为主,也可能几乎全是私立大学。这里按大学种类(国立、公立、私立)把柱子堆叠起来,代码与上面的例子有三点不同。

  1. 在 group_by() 里加上 type_jp,按“都道府县 × 种类”分组。

  2. 排序依据是“每个都道府县的总数”,而不是单根柱子的高度;在表达式中用 over() 做分组聚合即可实现(over() 的用法)。

  3. by="type_jp" 按种类着色,stacked=True 堆叠显示。

(df3
.group_by(
    pl.col("state_jp").alias(x_label), 
    "type_jp"
)
.agg(
    pl.len().alias(y_label)
)
.sort(pl.col(y_label).sum().over(x_label), descending=True)
.hvplot.bar(
    x=x_label, y=y_label, by="type_jp", 
    stacked=True, rot=90, 
    frame_width=900, frame_height=450
)
)

最后把经纬度转换成点,按大学种类着色,再叠加到地图上,用来观察各都道府县的学校分布。

  • by="type_jp":按种类着色。

  • hover_cols=['name_jp']:鼠标悬停时显示大学名称。

  • tiles=True:铺上地图底图。

请注意不要同时指定 geo=True:在 geoviews 1.15 与 pyproj 3.8 的组合下,它会使投影计算直接报错;因此,只保留 tiles=True,地图即可正常显示。

df3.hvplot.points(
    "longitude",
    "latitude",
    by="type_jp",
    hover_cols=["name_jp"],
    tiles=True,
    frame_width=600,
)