2025, Nov 17 21:02

Безопасная выборка из списков в Polars: 2 с начала, 2 из середины и 2 с конца

Polars на практике: берём 2 из начала, 2 из середины (случайно) и 2 с конца списка. Объясняем сбой sample и показываем, как избежать ShapeError надёжно.

Выборка из столбцов-списков в Polars обычно проста, пока не требуется совместить фиксированные срезы со случайной подвыборкой и при этом не «сломаться» на коротких списках. В этом руководстве показано, как взять первые два значения, последние два значения и два случайных значения из середины каждого списка, а затем оставить только уникальные элементы. Также объясняется, почему наивный подход даёт сбой и как реализовать устойчивое решение, работающее при любой длине списка.

Воспроизводим пример и сбой

В данных есть столбец со списками; требуется взять два элемента с начала, два случайных из средней части и два с конца. Если в списке шесть элементов или меньше, вернуть весь список целиком. Ниже компактный пример и первая попытка, которая ломается:

import polars as pl
data = pl.DataFrame(
    {
        "key": ["a", "b"],
        "nums": [[1, 2, 3, 4, 5], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]],
    }
)
broken = (
    data.select(
        top=pl.col("nums").list.head(2),
        mid=pl.col("nums")
            .list.set_difference(pl.col("nums").list.head(2))
            .list.set_difference(pl.col("nums").list.tail(2))
            .list.sample(2, seed=1234),
        bottom=pl.col("nums").list.tail(2),
    )
    .select(pick=pl.concat_list(["top", "mid", "bottom"]).list.unique())
)

Эта попытка приводит к исключению, потому что в коротких списках средний срез может содержать меньше двух элементов:

ShapeError: cannot take a larger sample than the total population when `with_replacement=false`

Почему это ломается

Средняя часть вычисляется путём удаления из списка первых двух и последних двух значений. Для списков длиной пять и меньше остаток оказывается меньше двух. Выборка без возвращения при n, превышающем доступную «популяцию», недопустима — именно это и вызывает ShapeError.

Идиоматичный способ выбрать два из середины

Вместо того чтобы просить list.sample выдать фиксированное количество, которое может оказаться недостижимым, перемешайте средний сегмент и возьмите первые два из перемешанного результата. При fraction=1 и shuffle=True средний список перемешивается, но не меняет размер, а head(2) безопасно ограничивает результат двумя или меньшим числом значений в зависимости от доступности.

data.select(
    pl.col("nums")
      .list.head(pl.col("nums").list.len() - 2)
      .list.slice(2)
      .list.sample(fraction=1, shuffle=True)
      .list.head(2)
      .alias("mid_two")
)

Это выражение выделяет среднюю часть, отрезая по два элемента с каждого края, перемешивает её и выбирает до двух значений без риска «пересэмплинга».

Собираем итоговую выборку с проверкой длины

Далее нужно объединить начало, перемешанную середину и конец, а при слишком коротком списке вернуть исходный. Условие переключения — длина списка больше пяти. В результате сохраняются только уникальные элементы, как и требуется.

result = data.with_columns(
    pl.when(pl.col("nums").list.len() > 5)
      .then(
          pl.concat_list(
              pl.col("nums").list.head(2),
              pl.col("nums")
                .list.head(pl.col("nums").list.len() - 2)
                .list.slice(2)
                .list.sample(fraction=1, shuffle=True)
                .list.head(2),
              pl.col("nums").list.tail(2),
          )
      )
      .otherwise(pl.col("nums"))
      .list.unique()
      .alias("pick")
)

Для списков длиннее пяти это даёт два элемента с начала, два случайных из среднего сегмента и два с конца. Для более коротких списков возвращается исходный список без изменений. Применение list.unique гарантирует, что в итоговом списке останутся уникальные значения.

Альтернатива: сделать размер выборки условным

Другой вариант — не перемешивать среднюю часть и условно задавать, сколько элементов выбирать. Если передать в n выражение, то при недостаточной длине выборка вернёт ноль элементов, что делает всю конструкцию безопасной для любых размеров.

alt = data.with_columns(
    pl.when(pl.col("nums").list.len() > 5)
      .then(
          pl.concat_list(
              pl.col("nums").list.head(2),
              pl.col("nums")
                .list.head(pl.col("nums").list.len() - 2)
                .list.slice(2)
                .list.sample(
                    n=pl.when(pl.col("nums").list.len() > 5).then(2).otherwise(0)
                ),
              pl.col("nums").list.tail(2),
          )
      )
      .otherwise(pl.col("nums"))
      .list.unique()
      .alias("pick")
)

Этот вариант полностью избегает перемешивания и использует «ограничитель» размера выборки, чтобы предотвратить ошибки. Он даёт ту же форму результата и соблюдает требование уникальности.

Почему этот приём стоит запомнить

Сочетание детерминированных срезов со случайными выборками — частая задача при обработке данных. Связка «перемешать с fraction=1 и обрезать head» надёжна и избавляет от краевых случаев, когда требуемый размер выборки превышает доступную «популяцию». Переключение между преобразованным и исходным списком через when/then делает конвейер предсказуемым и удобным в сопровождении.

Итоги

Делая выборку из столбцов-списков в Polars, избегайте фиксированного n, которое может оказаться больше доступной «популяции». Либо перемешивайте и обрезайте, либо делайте n условным. Разбейте список с помощью list.head, list.tail и list.slice, используйте list.sample с fraction=1, shuffle=True, чтобы безопасно перемешать сегмент, и переключайтесь между исходным и собранным результатом через when/then. Завершайте list.unique, когда нужно удалить дубликаты в итоговой выборке.