0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PolarsのMap型で一次式を計算する

0
Last updated at Posted at 2026-10-06

Polars 2.0.0でMap型が導入されました。
本記事では、Map型とネームスペースの拡張を活用して一次式を保持・計算してみました。

ここでいう一次式とは、$ 3x + 2y + 1 $のような式です。

前提

  • Python: 3.14
  • Polars: 2.0.0

一次式の計算

Polarsで下記を計算してみましょう。

E1 = x
E2 = x + y
A = 2
B = 1
E1 + E2 * A + B → 3x + 2y + 1

このような計算は1次元のベクトルでできますが、0が多いとムダな計算をします。
ここでは、0の係数を保持しない疎な表現としてMap型を使います。

なお、今回の実装ではmap_elements()を介してPython側で計算を行っているため、実測パフォーマンスの向上というよりは「スパースな一次式をDataFrame上で直感的に扱えるDSL(拡張表現)のプロトタイプ」という位置づけになります。

E1列、E2列、A列、B列を持つ1行のDataFrame(df)を作成します(map_exprモジュールについては後述します)。

import polars as pl

from map_expr import MAP_EXPR_DTYPE

df = pl.DataFrame(
    {
        "E1": [{"x": 1}],
        "E2": [{"x": 1, "y": 1}],
        "A": [2],
        "B": [1],
    },
    schema_overrides={"E1": MAP_EXPR_DTYPE, "E2": MAP_EXPR_DTYPE},
)
print(df)

出力

shape: (1, 4)
┌───────────────┬──────────────────────┬─────┬─────┐
│ E1            ┆ E2                   ┆ A   ┆ B   │
│ ---           ┆ ---                  ┆ --- ┆ --- │
│ map[str, f64] ┆ map[str, f64]        ┆ i64 ┆ i64 │
╞═══════════════╪══════════════════════╪═════╪═════╡
│ {"x": 1.0}    ┆ {"x": 1.0, "y": 1.0} ┆ 2   ┆ 1   │
└───────────────┴──────────────────────┴─────┴─────┘

$ x + y $という一次式を{"x": 1.0, "y": 1.0}という辞書で保持します。
キーが変数名を、値が係数を意味します。

MAP_EXPR_DTYPEはpl.Map(pl.String, pl.Float64)です。
pl.Map(pl.String, pl.Float64)は、キーが文字列で値が実数を意味します。

続いて、E1 + E2 * A + Bを計算しましょう。しかし、Map型には四則演算がありません。
ここではlinというネームスペースを用意し、加減とスカラー倍・スカラー除算を定義しました。
次のように演算できます。

E1 = pl.col("E1")
E2 = pl.col("E2")
A = pl.col("A")
B = pl.col("B")

expr = E1.lin + E2.lin * A + B

print(df.select(Result=expr))

出力

shape: (1, 1)
┌───────────────────────────────┐
│ Result                        │
│ ---                           │
│ map[str, f64]                 │
╞═══════════════════════════════╡
│ {"x": 3.0, "y": 2.0, "": 1.0} │
└───────────────────────────────┘

定数項は変数名が存在しないため、特殊なキー""で表現します。
したがって{"x": 3.0, "y": 2.0, "": 1.0}は、$ 3 x + 2 y + 1 $を表します。

to_formula()を使うと数式で表現します。

print(df.select(Result=expr.to_formula()))

出力

shape: (1, 1)
┌─────────────────────────┐
│ Result                  │
│ ---                     │
│ str                     │
╞═════════════════════════╡
│ 3.0 * x + 2.0 * y + 1.0 │
└─────────────────────────┘

E1 + E2 * A + Bが3.0 * x + 2.0 * y + 1.0と表現できました。

まとめ

Polars 2.0.0で追加されたMap型を使うことで、0の係数を持たない疎な一次式をMapとして表現できます。

さらに、ネームスペースを利用して一次式用の演算を定義することで、Map型を使った一次式を通常の式のように計算できます。

今回の例では、E1 + E2 * A + Bを計算すると、{"x": 3.0, "y": 2.0, "": 1.0}というMapになり、to_formula()を使うことで3.0 * x + 2.0 * y + 1.0という数式として表現できました。

このように、PolarsのMap型とネームスペースを組み合わせることで、一次式を扱う独自のデータ表現を作ることができます。

参考:map_expr.pyの実装

map_expr.pyの実装は次の通りです。あくまで、スパースな一次式をDataFrameで扱うためのプロトタイプであることにご留意ください。

この実装では、Map同士の加減と、一次式のスカラー倍・スカラー除算を扱います。一般の二次式のような変数同士の乗算は扱いません。また、""を定数項に使用するため、変数名として空文字は使用できません。

また、この実装ではpl.Exprのprivateメソッドを使っています。この部分はPolarsのバージョンによって変更される可能性があります。

0除算をするとエラーではなくnullになります。

"""Mapを使った一次式の計算"""

import math
from numbers import Real
from typing import Any, Self

import polars as pl

MAP_EXPR_DTYPE = pl.Map(pl.String, pl.Float64)


def _to_dict_operand(val: Any) -> dict[str, float] | None:
    """行要素を一次式 dict に正規化する"""
    if val is None:
        return None
    if isinstance(val, Real):
        return {"": float(val)}
    if isinstance(val, dict):
        return {k: float(v) for k, v in val.items()}
    return None


def _simplify(value: dict[str, float]) -> dict[str, float]:
    return {k: v for k, v in value.items() if not math.isclose(v, 0.0, abs_tol=1e-9)}


def _add(row: dict[str, Any], *, is_add: bool = True) -> dict[str, float] | None:
    left = _to_dict_operand(row["left"])
    right = _to_dict_operand(row["right"])
    if left is None or right is None:
        return None

    result = dict(left)
    for key, value in right.items():
        result[key] = result.get(key, 0.0) + float(value if is_add else -value)
    return _simplify(result)


def _extract_scalar(val: Any) -> float | None:
    """スカラー値(実数または定数項のみのMap)を取り出す"""
    if isinstance(val, Real):
        return float(val)
    if isinstance(val, dict) and tuple(val) in {(), ("",)}:
        return float(val.get("", 0.0))
    return None


def _mul(row: dict[str, Any], *, is_mul: bool = True) -> dict[str, float] | None:
    left = row["left"]
    right = row["right"]
    if left is None or right is None:
        return None

    if is_mul:
        scale = _extract_scalar(right)
        vec_dict = _to_dict_operand(left)
        if scale is None:
            scale = _extract_scalar(left)
            vec_dict = _to_dict_operand(right)
        if scale is None or vec_dict is None:
            raise ValueError("乗算の片方は定数(数値または定数項のみのMap)である必要があります")
        return _simplify({k: v * scale for k, v in vec_dict.items()})

    scale = _extract_scalar(right)
    vec_dict = _to_dict_operand(left)
    if scale is None or vec_dict is None:
        raise ValueError("除算の右辺は定数である必要があります")
    if math.isclose(scale, 0.0, abs_tol=1e-12):
        return None  # 0除算は null 扱い
    return _simplify({k: v / scale for k, v in vec_dict.items()})


def _format_formula(value: dict[str, float] | None) -> str | None:
    if value is None:
        return None

    # 変数はアルファベット順、定数項("")は最後
    var_keys = sorted([k for k in value if k])
    keys = var_keys + ([""] if "" in value else [])

    terms = []
    for key in keys:
        coefficient = value[key]
        if math.isclose(coefficient, 0.0, abs_tol=1e-9):
            continue
        magnitude = abs(coefficient)
        if not key:
            term = str(magnitude)
        elif math.isclose(magnitude, 1.0, abs_tol=1e-9):
            term = key
        else:
            term = f"{magnitude} * {key}"
        terms.append((coefficient < 0, term))

    if not terms:
        return "0.0"

    negative, first = terms[0]
    formula = f"- {first}" if negative else first
    for negative, term in terms[1:]:
        formula += f" {'-' if negative else '+'} {term}"
    return formula


class MapExpr(pl.Expr):
    """Map式をラップし、四則演算と数式変換を提供するExpr"""

    def __new__(cls, expr: pl.Expr) -> Self:
        obj = super().__new__(cls)
        obj._pyexpr = expr._pyexpr  # ruff: ignore[private-member-access]
        return obj

    @property
    def expr(self) -> pl.Expr:
        return pl.Expr._from_pyexpr(self._pyexpr)  # ruff: ignore[private-member-access]

    @classmethod
    def _coerce(cls, value: Any) -> pl.Expr:
        if isinstance(value, MapExpr):
            return value.expr
        if isinstance(value, MapExprNamespace):
            return value.expr.expr
        if isinstance(value, pl.Expr):
            return value
        if isinstance(value, Real):
            return pl.lit({"": float(value)}, dtype=MAP_EXPR_DTYPE)
        raise TypeError(f"未対応の型です: {type(value)}")

    def __add__(self, other: Any) -> MapExpr:
        expr = pl.struct(left=self.expr, right=self._coerce(other))
        return MapExpr(expr.map_elements(_add, return_dtype=MAP_EXPR_DTYPE))

    def __radd__(self, other: Any) -> MapExpr:
        return self.__add__(other)

    def __sub__(self, other: Any) -> MapExpr:
        expr = pl.struct(left=self.expr, right=self._coerce(other))
        return MapExpr(
            expr.map_elements(lambda r: _add(r, is_add=False), return_dtype=MAP_EXPR_DTYPE),
        )

    def __rsub__(self, other: Any) -> MapExpr:
        expr = pl.struct(left=self._coerce(other), right=self.expr)
        return MapExpr(
            expr.map_elements(lambda r: _add(r, is_add=False), return_dtype=MAP_EXPR_DTYPE),
        )

    def __mul__(self, other: Any) -> MapExpr:
        expr = pl.struct(left=self.expr, right=self._coerce(other))
        return MapExpr(expr.map_elements(_mul, return_dtype=MAP_EXPR_DTYPE))

    def __rmul__(self, other: Any) -> MapExpr:
        return self.__mul__(other)

    def __truediv__(self, other: Any) -> MapExpr:
        expr = pl.struct(left=self.expr, right=self._coerce(other))
        return MapExpr(
            expr.map_elements(lambda r: _mul(r, is_mul=False), return_dtype=MAP_EXPR_DTYPE),
        )

    def to_formula(self) -> pl.Expr:
        return self.expr.map_elements(_format_formula, return_dtype=pl.String)


@pl.api.register_expr_namespace("lin")
class MapExprNamespace:
    """``pl.col("X").lin``のエントリポイント"""

    def __init__(self, expr: pl.Expr) -> None:
        self._map_expr = MapExpr(expr)

    @property
    def expr(self) -> MapExpr:
        return self._map_expr

    def __add__(self, other: Any) -> MapExpr:
        return self._map_expr + other

    def __radd__(self, other: Any) -> MapExpr:
        return self._map_expr.__radd__(other)

    def __sub__(self, other: Any) -> MapExpr:
        return self._map_expr - other

    def __rsub__(self, other: Any) -> MapExpr:
        return self._map_expr.__rsub__(other)

    def __mul__(self, other: Any) -> MapExpr:
        return self._map_expr * other

    def __rmul__(self, other: Any) -> MapExpr:
        return self._map_expr.__rmul__(other)

    def __truediv__(self, other: Any) -> MapExpr:
        return self._map_expr / other

    def to_formula(self) -> pl.Expr:
        return self._map_expr.to_formula()

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?