Polars是一个运用rust开发的类似于Pandas的Dataframe库,polars在很多当地的性能表现比pandas好不少,我现在测验在一些数据处理项目中运用polars去做。
最近在运用polars处理中文字符串长度的时分遇到一个小坑: str.lengths
函数回来的是字节数而不是字符数。
问题复现
python代码
import porars as pl
s = pl.Series(["string", "字符串"])
s.str.lengths()
输出结果如下:
shape: (2,)
Series: '' [u32]
[
6
9
]
其间字符串”string”核算的长度6是正确的,而”字符串””得到的长度是9而不是3。
网上搜了一下,没搜到相关问题(polars现在运用的人确实不多,网上的评论比pandas少太多了),去github issues也没搜到相关的问题。所以便决定自己排查一番,嫌烦琐的同学能够直接跳到后边看问题定论。
因为polars是rust开发,而rust中的字符串是运用utf8编码,所以想到问题或许出在rust字符串api上,写段rust代码测验一下:
#[test]
fn test_string_len() {
let s1 = String::from("string");
let s2 = String::from("字符串");
println!("英文字符串长度: {}", s1.len());
println!("中文字符串长度: {}", s2.len());
}
输出:
英文字符串长度: 6
中文字符串长度: 9
rust字符串api确实如此,那么接下来便是看看polars中字符串长度的完成是否与它有关了。
检查源码完成
先将polars的代码克隆到本地:
git clone https://github.com/pola-rs/polars.git
然后运用IDE或者编辑器翻开它(我运用clion)
python接口代码在py-polars
目录,再用pycharm翻开这个目录(个人觉得pycharm提示跳转比较好,方便跟踪剖析代码)。
咱们前面的代码s.str.lengths()
中,s
是polars.Series
, 故先找到它,但凡python项目,先看包的__init__.py
文件,看看引用的东西都是哪里来的,这儿咱们先看py-polars/polars/__init__.py
文件, 其间:
from polars.internals.series import Series
然后直接跳转到Series
源码文件(py-polars/polars/internals/series/series.py
), 发现Series
是一个python的class,部分代码:
@expr_dispatch
class Series:
@property
def str(self) -> StringNameSpace:
"""Create an object namespace of all string related methods."""
return StringNameSpace(self)
其间str
特点办法回来的是StringNameSpace
, 下一步便是检查它,StringNameSpace
也是一个class, 部分代码:
@expr_dispatch
class StringNameSpace:
"""Series.str namespace."""
_accessor = "str"
def __init__(self, series: pli.Series):
self._s: PySeries = series._s
def lengths(self) -> pli.Series:
找到了其间的lengths
办法,what???,没有完成代码,不对呀,这样不会报错么? 发现也没有加@typing.overload
装修器,那就或许是其他的当地对这个类做了修正,天然就想到了python的装修器, 公然StringNameSpace
类上有个一个装修器@expr_dispatch
,见名知义,这个装修器做的应该便是将一些操作或者表达式转发到其它当地。
下一步,检查expr_dispatch
装修器源码,
def expr_dispatch(cls: type[T]) -> type[T]:
# 先检查类cls(这儿是: StringNameSpace) 中的特点称号"_accessor"的值, 这儿得到namespace是"str"
namespace = getattr(cls, "_accessor", None)
# 然后依据namenode查找表达式完成
expr_lookup = _expr_lookup(namespace)
for name in dir(cls):
# 遍历类cls的办法特点等
if not name.startswith("_"):
attr = getattr(cls, name)
if callable(attr):
# 假如是一个可调用的目标(这儿主要是办法)
args = attr.__code__.co_varnames[: attr.__code__.co_argcount]
if (namespace, name, args) in expr_lookup and _is_empty_method(attr):
# 假如命名空间,称号和参数在表达式完成expr_lookup中,则掩盖当时类型的办法
setattr(cls, name, call_expr(attr))
return cls
这个装修器本质上便是修正被装修的类,将它的一些办法完成转为表达式的完成,详细转发细节比较绕,这儿先不讲了,字符串表达式的完成ExprStringNameSpace
在文件py-polars/polars/internals/expr/string.py
中,检查代码:
class ExprStringNameSpace:
_accessor = "str"
def __init__(self, expr: pli.Expr):
self._pyexpr = expr._pyexpr
def lengths(self) -> pli.Expr:
return pli.wrap_expr(self._pyexpr.str_lengths())
这儿的lengths是通过调用self._pyexpr.str_lengths()
完成的,其间_pyexpr
对应到rust的PyExpr
,polars通过pyo3在python和rust间交互, 其间py-polars
模块便是一个pyo3的项目,先检查py-polars/src/lib.rs
,看看polars给python露出的模块, 部分代码:
#[pymodule]
fn polars(py: Python, m: &PyModule) -> PyResult<()> {
...
m.add_class::<PySeries>().unwrap();
m.add_class::<PyDataFrame>().unwrap();
m.add_class::<PyLazyFrame>().unwrap();
m.add_class::<PyLazyGroupBy>().unwrap();
m.add_class::<dsl::PyExpr>().unwrap();
...
}
下一步便是跳到rust的dsl::PyExpr
代码中检查(py-polars/src/lazy/dsl.rs
)
#[pyclass]
#[repr(transparent)]
#[derive(Clone)]
pub struct PyExpr {
pub inner: dsl::Expr,
}
#[pymethods]
impl PyExpr {
pub fn str_lengths(&self) -> PyExpr {
let function = |s: Series| {
// 将Series转为utf8的 &Utf8Chunked
let ca = s.utf8()?;
// Utf8Chunked完成了Utf8NameSpaceImpl特征
Ok(ca.str_lengths().into_series())
};
self.clone()
.inner
.map(function, GetOutput::from_type(DataType::UInt32))
.with_fmt("str.lengths")
.into()
}
}
PyExpr
便是dsl::Expr
的包装结构体,这儿通过将函数function
运用到dsl::Expr
中,在函数function
对Series
进行处理。上述代码中通过ca.str_lengths()
来核算字符串的长度, ca是&Utf8Chunked
, Utf8Chunked
是ChunkedArray<Utf8Type>
的类型别号, ChunkedArray
是polars的底层内存布局,polars中的数据的内存存储格局是Arrow,ChunkedArray
是对Arrow的封装, Utf8Chunked
完成了Utf8NameSpaceImpl
特征, Utf8NameSpaceImpl
部分代码:
pub trait Utf8NameSpaceImpl: AsUtf8 {
fn str_lengths(&self) -> UInt32Chunked {
let ca = self.as_utf8();
ca.apply_kernel_cast(&string_lengths)
}
}
这儿的apply_kernel_cast
是为了将函数string_lengths
运用Utf8Chunked
的每个chunked中(这儿即Series的每个元素),那string_lengths
便是终究咱们找的代码啦:
pub fn string_lengths(array: &Utf8Array<i64>) -> ArrayRef {
// 通过arrow存储的偏移核算长度
let values = array.offsets().windows(2).map(|x| (x[1] - x[0]) as u32);
let values: Buffer<_> = Vec::from_trusted_len_iter(values).into();
let array = UInt32Array::from_data(DataType::UInt32, values, array.validity().cloned());
Box::new(array)
}
在arrow中,对于变长数据的存储主要由数据数组和偏移数组构成(存储结构示意如下),第ii个元素的长度为:offset[i + 1] - offset[i]
,因为polars运用了utf8编码字符串, “string”每个字符都是英文字母,每个字符占用一个字节,所以”string”的长度为6, 而”字符串”中每个字符都是中文字符,正好这几个中文字符每个都占用3个字节,所以长度为15−6=915 – 6 = 9
┌────────┬────────┐
│ data ┆ offset │
╞════════╪════════╡
│ ┆ 0 │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ string ┆ 6 │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ 字符串 ┆ 15 │
└────────┴────────┘
问题定论
到这也根本清晰了,polars对于中文字符串长度核算的问题,主要跟polars的对字符串运用utf8编码以及底层arrow存储有关,与我猜想的或许是rust字符串api导致的没有直接关系。
从rust规划理念来看,直接回来字符串的字节数形似没什么问题,究竟rust字符串的len
函数回来的便是字符串的字节数,别的rust字符串直接回来字节数的时间复杂度是O(1)
,rust没有直接供给获取字符数量的api,当然也能够通过s.chars().count()
获得字符数量,但是这儿的时间复杂度便是O(n)
了。
但是从数据剖析师的视点,个人认为绝大部分情况都是希望获取字符串的长度而不是字节数,当然有一个暂时的核算办法:
import porars as pl
s = pl.Series(["string", "字符串"])
s.str.split(by="").arr.lengths().apply(lambda l: l - 2 if l >= 2 else l)
shape: (2,)
Series: '' [i64]
[
6
3
]
这个完成真实丑陋且功率一般。
社区问题反应
个人觉得能够供给一个新的api来回来字符串的长度,所以便去github提了这个issues,社区大佬立马跟进并提了PR,很快呀,通过简单评论,之前的str.lengths
api不变,仍然回来字符串占用的字节数,新增一个str.n_chars
api来回来字符串中字符的数量。现在最新版别的polars中现已包含了这个api,所以求字符串长度能够直接运用了:
import porars as pl
s = pl.Series(["string", "字符串"])
s.str.n_chars()
shape: (2,)
Series: '' [u32]
[
6
3
]
开源库踩坑思路
总结上面的流程,我了解的踩坑思路大概是这样:
- 运用库并发现问题
- 搜索引擎或者项目issues等搜搜相关问题
- 假如还无法处理,斗胆猜想一下导致问题的原因,或许的话做做简单的验证
- 拉取库的源码,结合问题和猜想逐步剖析并检查相关完成
- issues中反应问题
- 依据issues的评论,能够的就考虑提交PR处理相关问题
最后
通过这一番折腾,发现polars整体规划还是很不错的(基于arrow的存储规划、惰性求值和执行计划优化等等),后续有空能够再研究研究写几篇原了解析的文章。
别的对rust语言感兴趣并想做一些项目实践的话(没错,便是我啦),polars值得一试,个人感觉polars对sql的和更多数据源的支持以及多语言api都是一些不错的值得做的方向。