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()中,spolars.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中,在函数functionSeries进行处理。上述代码中通过ca.str_lengths()来核算字符串的长度, ca是&Utf8Chunked, Utf8ChunkedChunkedArray<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.lengthsapi不变,仍然回来字符串占用的字节数,新增一个str.n_charsapi来回来字符串中字符的数量。现在最新版别的polars中现已包含了这个api,所以求字符串长度能够直接运用了:

import porars as pl
s = pl.Series(["string", "字符串"])
s.str.n_chars()
shape: (2,)
Series: '' [u32]
[
	6
	3
]

开源库踩坑思路

总结上面的流程,我了解的踩坑思路大概是这样:

  1. 运用库并发现问题
  2. 搜索引擎或者项目issues等搜搜相关问题
  3. 假如还无法处理,斗胆猜想一下导致问题的原因,或许的话做做简单的验证
  4. 拉取库的源码,结合问题和猜想逐步剖析并检查相关完成
  5. issues中反应问题
  6. 依据issues的评论,能够的就考虑提交PR处理相关问题

最后

通过这一番折腾,发现polars整体规划还是很不错的(基于arrow的存储规划、惰性求值和执行计划优化等等),后续有空能够再研究研究写几篇原了解析的文章。

别的对rust语言感兴趣并想做一些项目实践的话(没错,便是我啦),polars值得一试,个人感觉polars对sql的和更多数据源的支持以及多语言api都是一些不错的值得做的方向。