Как вычислить столбец в фрейме данных Polars, используя np.linspace

Обратите внимание на следующее pl.DataFrame:

df = pl.DataFrame(
    data = {
        "np_linspace_start": [0, 0, 0], 
        "np_linspace_stop": [8, 6, 7],
        "np_linspace_num": [5, 4, 4]
    }
)

shape: (3, 3)
┌───────────────────┬──────────────────┬─────────────────┐
│ np_linspace_start ┆ np_linspace_stop ┆ np_linspace_num │
│ ---               ┆ ---              ┆ ---             │
│ i64               ┆ i64              ┆ i64             │
╞═══════════════════╪══════════════════╪═════════════════╡
│ 0                 ┆ 8                ┆ 5               │
│ 0                 ┆ 6                ┆ 4               │
│ 0                 ┆ 7                ┆ 4               │
└───────────────────┴──────────────────┴─────────────────┘

Как я могу создать новый столбец ls, который является результатом функции np.linspace? В этом столбце будет стоять np.array.

Я искал что-то в этом роде:

df.with_columns(
    ls=np.linspace(
        start=pl.col("np_linspace_start"),
        stop=pl.col("np_linspace_stop"),
        num=pl.col("np_linspace_num")
    )
)

Есть ли polars эквивалент np.linspace?

github.com/pola-rs/polars/issues/5255
Dogbert 11.07.2024 14:47
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
4
1
125
4
Перейти к ответу Данный вопрос помечен как решенный

Ответы 4

Ответ принят как подходящий

Как упоминалось в комментариях, добавление функции в стиле np.linspace в поляры — это открытый запрос на добавление функции. Пока это не реализовано, простая реализация с использованием собственного API выражений поляров может выглядеть следующим образом.

Во-первых, мы используем pl.int_range (спасибо @Dean MacGregor), чтобы создать диапазон целых чисел от 0 до num (эксклюзивный). Затем мы масштабируем и сдвигаем диапазон в соответствии с start, stop и num. Наконец, мы включаем столбец с помощью pl.Expr.implode, чтобы получить столбец со списком диапазона для каждой строки.

def pl_linspace(start: pl.Expr, stop: pl.Expr, num: pl.Expr) -> pl.Expr:
    grid = pl.int_range(num)
    _scale = (stop - start) / (num - 1)
    _offset = start
    return (grid * _scale + _offset).implode().over(pl.int_range(pl.len()))

df.with_columns(
    pl_linspace(
        start=pl.col("np_linspace_start"),
        stop=pl.col("np_linspace_stop"),
        num=pl.col("np_linspace_num"),
    ).alias("pl_linspace")
)
shape: (3, 4)
┌───────────────────┬──────────────────┬─────────────────┬────────────────────────────────┐
│ np_linspace_start ┆ np_linspace_stop ┆ np_linspace_num ┆ pl_linspace                    │
│ ---               ┆ ---              ┆ ---             ┆ ---                            │
│ i64               ┆ i64              ┆ i64             ┆ list[f64]                      │
╞═══════════════════╪══════════════════╪═════════════════╪════════════════════════════════╡
│ 0                 ┆ 8                ┆ 5               ┆ [0.0, 2.0, 4.0, 6.0, 8.0]      │
│ 0                 ┆ 6                ┆ 4               ┆ [0.0, 2.0, 4.0, 6.0]           │
│ 0                 ┆ 7                ┆ 4               ┆ [0.0, 2.333333, 4.666667, 7.0] │
└───────────────────┴──────────────────┴─────────────────┴────────────────────────────────┘

Примечание. Если num равно 1, то деление при вычислении _scale приведет к бесконечным значениям. Этого можно избежать, добавив следующее к pl_linspace.

_scale = pl.when(_scale.is_infinite()).then(pl.lit(0)).otherwise(_scale)

Небольшая неприятность: не уверен, как это повлияет на производительность, но если вы установите grid = pl.int_range(num), то в возвращаемой строке вы можете просто сделать grid вместо grid.list.explode()

Dean MacGregor 11.07.2024 19:46

@DeanMacGregor Спасибо! Я отредактировал ответы. При кратком сравнении с использованием большего кадра данных из вашего ответа время выполнения сократилось примерно вдвое.

Hericks 11.07.2024 20:39

Мне очень нравится ответ @Hericks, но он поднимает проблему, которая возникает время от времени, когда нам нужно обойти невозможность заставить списки выполнять операции с другими списками, explodeing, а затем implodeing с овером. Это over действительно дорого, и если мы привлечем нашего старого приятеля pyarrow, чтобы помочь нам, мы сможем этого избежать.

Вот альтернатива pl_linspace, использующая pyarrow.

import pyarrow as pa
def pals_linspace(
    start: pl.Expr | str, stop: pl.Expr | str, num: pl.Expr | str
) -> pl.Expr:
    if isinstance(start, str):
        start = pl.col(start)
    if isinstance(stop, str):
        stop = pl.col(stop)
    if isinstance(num, str):
        num = pl.col(num)
        
    grid = pl.int_ranges(num)
    # Note the repeat_by here to broadcast the scalers to the same shape as grid
    scale = ((stop - start) / (num - 1)).repeat_by(grid.list.len())
    offset = start.repeat_by(grid.list.len())
    
    # Need to make a struct so all the columns we need are in a Series
    all_struct = pl.struct(
        grid.alias("grid"), scale.alias("scale"), offset.alias("offset")
    )
    return all_struct.map_batches(
        lambda s: (
            pl.from_arrow(
                # This is the key to avoiding over, its first argument is the offsets
                # of the list we want to end up with. Since we want to end up with the 
                # same shape as `grid` we can just use that as-is
                
                # The second argument is just @Herick's formula
                pa.LargeListArray.from_arrays(
                    s.struct.field("grid").to_arrow().offsets,
                    (
                        s.struct.field("grid").explode()
                        * s.struct.field("scale").explode()
                        + s.struct.field("offset").explode()
                    ).to_arrow(),
                )
            )
        )
    )

Производительность

Если я сделаю %%timeit из pl_linspace и pals_linspace с заданным df, то pl_linspace будет быстрее. Однако если мы сделаем

df = pl.DataFrame(
    data = {
        "np_linspace_start": [0, 0, 0] * 100,
        "np_linspace_stop": [8, 6, 7] * 100,
        "np_linspace_num": [5, 4, 4] * 100,
    }
)

затем

%%timeit 
df.with_columns(
    ls=pals_linspace("np_linspace_start", "np_linspace_stop", "np_linspace_num")
)
2.95 ms ± 259 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%%timeit
df.with_columns(
    pl_linspace(
        start=pl.col("np_linspace_start"),
        stop=pl.col("np_linspace_stop"),
        num=pl.col("np_linspace_num"),
    ).alias("pl_linspace")
)
9.9 ms ± 722 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Очень хорошо! Я тестировал локально и на своей машине. Даже после оптимизации производительности, о которой вы упомянули выше (использование pl.int_range() вместо pl.int_ranges().explode()), при прямом использовании pyarrow все равно наблюдается двукратное улучшение.

Hericks 11.07.2024 20:47

Я думаю, что оба ответа хороши, но когда я читал ответ @Dean MacGregor, мне стало интересно, сможем ли мы сделать это быстрее.

На данный момент я не думаю, что мы можем сделать чистое полярное решение, которое будет быстрее, но я обнаружил, что обычно комбинация Polars + DuckDB работает очень хорошо.

Итак, вот duckdb решение, которое использует функции generate_series() и list_transform():

duckdb.sql("""
    select
        np_linspace_start,
        np_linspace_stop,
        np_linspace_num,
        list_transform(
            generate_series(0, np_linspace_num - 1),
            x -> x * np_linspace_stop / (np_linspace_num - 1)
        ) as pl_linspace
    from df
""")

┌───────────────────┬──────────────────┬─────────────────┬────────────────────────────────┐
│ np_linspace_start ┆ np_linspace_stop ┆ np_linspace_num ┆ pl_linspace                    │
│ ---               ┆ ---              ┆ ---             ┆ ---                            │
│ i64               ┆ i64              ┆ i64             ┆ list[f64]                      │
╞═══════════════════╪══════════════════╪═════════════════╪════════════════════════════════╡
│ 0                 ┆ 8                ┆ 5               ┆ [0.0, 2.0, 4.0, 6.0, 8.0]      │
│ 0                 ┆ 6                ┆ 4               ┆ [0.0, 2.0, 4.0, 6.0]           │
│ 0                 ┆ 7                ┆ 4               ┆ [0.0, 2.333333, 4.666667, 7.0] │
└───────────────────┴──────────────────┴─────────────────┴────────────────────────────────┘

В моих тестах это работало примерно в 5 раз быстрее, чем полярное решение с explode(), и примерно в 2 раза быстрее, чем pyarrow решение:

%%timeit

duckdb.sql("""
    select
        np_linspace_start,
        np_linspace_stop,
        np_linspace_num,
        list_transform(
            generate_series(0, np_linspace_num - 1),
            x -> x * np_linspace_stop / (np_linspace_num - 1)
        ) as pl_linspace
    from df
""")

472 µs ± 47 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
%%timeit

df.with_columns(
    pl_linspace(
        start=pl.col("np_linspace_start"),
        stop=pl.col("np_linspace_stop"),
        num=pl.col("np_linspace_num"),
    ).alias("pl_linspace")
)

2.1 ms ± 341 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%%timeit

df.with_columns(
    ls=pals_linspace("np_linspace_start", "np_linspace_stop", "np_linspace_num")
)

826 µs ± 69.9 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

обновление Как отметил Дин МакГрегор в комментариях, код duckdb не включает метод .pl(), который бы преобразовывал результаты обратно в поляры. Я не делал этого, потому что, по моему опыту, преобразование в поляры не масштабируется линейно с размером кадра данных. Наш тестовый фрейм данных состоит всего из 300 строк, поэтому преобразование в поляры может занять значительную часть времени выполнения.

Я попробовал все три метода на более крупном фрейме данных (3M строк), это, конечно, более гибко, потому что я не запускал его в цикле. Похоже, метод pyarrow побеждает, а duckdb занимает второе место. В 30M строках мне не хотелось ждать достаточно долго, пока решение explode() завершится, но оба решения pyarrow и duckdb сработали в течение 20 секунд.

%%time

a = df.with_columns(
    pl_linspace(
        start=pl.col("np_linspace_start"),
        stop=pl.col("np_linspace_stop"),
        num=pl.col("np_linspace_num"),
    ).alias("pl_linspace")
)

CPU times: total: 6.36 s
Wall time: 18 s
%%time

a = duckdb.sql("""
    select
        np_linspace_start,
        np_linspace_stop,
        np_linspace_num,
        list_transform(generate_series(0, np_linspace_num - 1), x -> x * np_linspace_stop / (np_linspace_num - 1)) as pl_linspace
    from df
""").pl()

CPU times: total: 2.08 s
Wall time: 1.03 s
%%time

a = df.with_columns(
    ls=pals_linspace("np_linspace_start", "np_linspace_stop", "np_linspace_num")
)

CPU times: total: 781 ms
Wall time: 1.29 s

Вы забыли поставить .pl() в конце звонка duckdb.sql, так что это не совсем «яблоки к яблокам». Когда я добавляю .pl() в конце, чтобы он возвращал полярный df, он превосходит over, но только примерно на 26%.

Dean MacGregor 11.07.2024 20:36

Честно говоря, по моему опыту, преобразование в полярные поля не масштабируется линейно с размером кадра данных, поэтому я этого не делал — наш фрейм данных состоит всего из 100 строк, поэтому преобразование в полярные поля может занять значительную часть времени выполнения. Я собираюсь попробовать эти решения на больших кадрах данных, чтобы проверить это.

Roman Pekar 11.07.2024 20:39

@DeanMacGregor спасибо за комментарий, смотрите обновленную версию. Похоже, что чистый метод Pyarrow побеждает, но DuckDB достаточно быстр для своей простоты.

Roman Pekar 11.07.2024 21:05

Итоговые списки должны выглядеть примерно так: ._ranges(start, pl.col.stop + 1, (stop - start) / (num - 1). Проблема, с которой мы сейчас столкнулись, заключается в том, что (stop - start) / (num - 1) не является целым числом, поэтому мы не можем использовать int_ranges().

Вопрос в том, можем ли мы как-то модифицировать решения, которые мы могли бы создать int_ranges(), а затем преобразовать их в результирующие float_ranges()?

Мы не можем умножать элементы списка на другой столбец (пока). Но что мы можем сделать, так это умножить или разделить элементы списка по переменной.

А что, если бы у нас было такое число p, что выражение p * (stop - start) / (num - 1) для всех строк приводило бы к целому числу? Тогда мы могли бы создавать списки int_ranges(), например списки .int_ranges(p * start, p * pl.col.stop + 1, p * (stop - start) / (num - 1), а затем просто делить все элементы на p.

Для этого нам нужно найти наименьшее общее кратное всех (num - 1) чисел и разделить его на наибольший общий делитель числа (stop - start). Я не думаю, что функция Polars для этого существует, но мы можем использовать numpy.lcm() и numpy.gcd().

Тогда решение будет выглядеть так:

n1 = list(df.select(pl.col.np_linspace_num - 1).unique().to_series())
n2 = list(df.select(pl.col.np_linspace_stop - pl.col.np_linspace_start).unique().to_series())
p = np.lcm.reduce(n1) / np.gcd.reduce(n2)

df.with_columns(
    pl
    .int_ranges(
        p * pl.col.np_linspace_start,
        p * (pl.col.np_linspace_stop + 1),
        p * (pl.col.np_linspace_stop - pl.col.np_linspace_start) / (pl.col.np_linspace_num - 1)
    )
    .list.eval(pl.element() / p)
    .alias("ls")
)

┌───────────────────┬──────────────────┬─────────────────┬────────────────────────────────┐
│ np_linspace_start ┆ np_linspace_stop ┆ np_linspace_num ┆ ls                             │
│ ---               ┆ ---              ┆ ---             ┆ ---                            │
│ i64               ┆ i64              ┆ i64             ┆ list[f64]                      │
╞═══════════════════╪══════════════════╪═════════════════╪════════════════════════════════╡
│ 0                 ┆ 8                ┆ 5               ┆ [0.0, 2.0, 4.0, 6.0, 8.0]      │
│ 0                 ┆ 6                ┆ 4               ┆ [0.0, 2.0, 4.0, 6.0]           │
│ 0                 ┆ 7                ┆ 4               ┆ [0.0, 2.333333, 4.666667, 7.0] │
└───────────────────┴──────────────────┴─────────────────┴────────────────────────────────┘

Это также кажется довольно быстрым в 3M строках:

%%time

n1 = list(df.select(pl.col.np_linspace_num - 1).unique().to_series())
n2 = list(df.select(pl.col.np_linspace_stop - pl.col.np_linspace_start).unique().to_series())
p = np.lcm.reduce(n1) / np.gcd.reduce(n2)

a = df.with_columns(
    pl
    .int_ranges(
        p * pl.col.np_linspace_start,
        p * (pl.col.np_linspace_stop + 1),
        p * (pl.col.np_linspace_stop - pl.col.np_linspace_start) / (pl.col.np_linspace_num - 1)
    )
    .list.eval(pl.element() / p)
    .alias("ls")
)

CPU times: total: 750 ms
Wall time: 908 ms

Другие вопросы по теме