Масштабирование до больших наборов данных#

pandas предоставляет структуры данных для аналитики в памяти, что делает использование pandas для анализа наборов данных, превышающих объем памяти, несколько сложным. Даже наборы данных, которые составляют значительную часть памяти, становятся громоздкими, поскольку некоторым операциям pandas требуется создавать промежуточные копии.

Этот документ содержит несколько рекомендаций по масштабированию вашего анализа для больших наборов данных. Он дополняет Улучшение производительности, который фокусируется на ускорении анализа для наборов данных, помещающихся в память.

Загрузить меньше данных#

Предположим, что наш исходный набор данных на диске имеет много столбцов.

In [1]: import pandas as pd

In [2]: import numpy as np

In [3]: def make_timeseries(start="2000-01-01", end="2000-12-31", freq="1D", seed=None):
   ...:     index = pd.date_range(start=start, end=end, freq=freq, name="timestamp")
   ...:     n = len(index)
   ...:     state = np.random.RandomState(seed)
   ...:     columns = {
   ...:         "name": state.choice(["Alice", "Bob", "Charlie"], size=n),
   ...:         "id": state.poisson(1000, size=n),
   ...:         "x": state.rand(n) * 2 - 1,
   ...:         "y": state.rand(n) * 2 - 1,
   ...:     }
   ...:     df = pd.DataFrame(columns, index=index, columns=sorted(columns))
   ...:     if df.index[-1] == end:
   ...:         df = df.iloc[:-1]
   ...:     return df
   ...: 

In [4]: timeseries = [
   ...:     make_timeseries(freq="1min", seed=i).rename(columns=lambda x: f"{x}_{i}")
   ...:     for i in range(10)
   ...: ]
   ...: 

In [5]: ts_wide = pd.concat(timeseries, axis=1)

In [6]: ts_wide.head()
Out[6]: 
                     id_0 name_0       x_0  ...   name_9       x_9       y_9
timestamp                                   ...                             
2000-01-01 00:00:00   977  Alice -0.821225  ...  Charlie -0.957208 -0.757508
2000-01-01 00:01:00  1018    Bob -0.219182  ...    Alice -0.414445 -0.100298
2000-01-01 00:02:00   927  Alice  0.660908  ...  Charlie -0.325838  0.581859
2000-01-01 00:03:00   997    Bob -0.852458  ...      Bob  0.992033 -0.686692
2000-01-01 00:04:00   965    Bob  0.717283  ...  Charlie -0.924556 -0.184161

[5 rows x 40 columns]

In [7]: ts_wide.to_parquet("timeseries_wide.parquet")

Для загрузки нужных столбцов у нас есть два варианта. Вариант 1 загружает все данные, а затем фильтрует то, что нам нужно.

In [8]: columns = ["id_0", "name_0", "x_0", "y_0"]

In [9]: pd.read_parquet("timeseries_wide.parquet")[columns]
Out[9]: 
                     id_0 name_0       x_0       y_0
timestamp                                           
2000-01-01 00:00:00   977  Alice -0.821225  0.906222
2000-01-01 00:01:00  1018    Bob -0.219182  0.350855
2000-01-01 00:02:00   927  Alice  0.660908 -0.798511
2000-01-01 00:03:00   997    Bob -0.852458  0.735260
2000-01-01 00:04:00   965    Bob  0.717283  0.393391
...                   ...    ...       ...       ...
2000-12-30 23:56:00  1037    Bob -0.814321  0.612836
2000-12-30 23:57:00   980    Bob  0.232195 -0.618828
2000-12-30 23:58:00   965  Alice -0.231131  0.026310
2000-12-30 23:59:00   984  Alice  0.942819  0.853128
2000-12-31 00:00:00  1003  Alice  0.201125 -0.136655

[525601 rows x 4 columns]

Вариант 2 загружает только запрошенные столбцы.

In [10]: pd.read_parquet("timeseries_wide.parquet", columns=columns)
Out[10]: 
                     id_0 name_0       x_0       y_0
timestamp                                           
2000-01-01 00:00:00   977  Alice -0.821225  0.906222
2000-01-01 00:01:00  1018    Bob -0.219182  0.350855
2000-01-01 00:02:00   927  Alice  0.660908 -0.798511
2000-01-01 00:03:00   997    Bob -0.852458  0.735260
2000-01-01 00:04:00   965    Bob  0.717283  0.393391
...                   ...    ...       ...       ...
2000-12-30 23:56:00  1037    Bob -0.814321  0.612836
2000-12-30 23:57:00   980    Bob  0.232195 -0.618828
2000-12-30 23:58:00   965  Alice -0.231131  0.026310
2000-12-30 23:59:00   984  Alice  0.942819  0.853128
2000-12-31 00:00:00  1003  Alice  0.201125 -0.136655

[525601 rows x 4 columns]

Если бы мы измерили использование памяти двух вызовов, мы бы увидели, что указание columns использует примерно 1/10 памяти в этом случае.

С pandas.read_csv(), вы можете указать usecols для ограничения количества столбцов, загружаемых в память. Не все форматы файлов, которые можно прочитать с помощью pandas, предоставляют возможность чтения подмножества столбцов.

Используйте эффективные типы данных#

Типы данных pandas по умолчанию не являются наиболее эффективными по памяти. Это особенно верно для текстовых столбцов данных с относительно небольшим количеством уникальных значений (обычно называемых данными с "низкой кардинальностью"). Используя более эффективные типы данных, вы можете хранить большие наборы данных в памяти.

In [11]: ts = make_timeseries(freq="30s", seed=0)

In [12]: ts.to_parquet("timeseries.parquet")

In [13]: ts = pd.read_parquet("timeseries.parquet")

In [14]: ts
Out[14]: 
                       id     name         x         y
timestamp                                             
2000-01-01 00:00:00  1041    Alice  0.889987  0.281011
2000-01-01 00:00:30   988      Bob -0.455299  0.488153
2000-01-01 00:01:00  1018    Alice  0.096061  0.580473
2000-01-01 00:01:30   992      Bob  0.142482  0.041665
2000-01-01 00:02:00   960      Bob -0.036235  0.802159
...                   ...      ...       ...       ...
2000-12-30 23:58:00  1022    Alice  0.266191  0.875579
2000-12-30 23:58:30   974    Alice -0.009826  0.413686
2000-12-30 23:59:00  1028  Charlie  0.307108 -0.656789
2000-12-30 23:59:30  1002    Alice  0.202602  0.541335
2000-12-31 00:00:00   987    Alice  0.200832  0.615972

[1051201 rows x 4 columns]

Теперь давайте проверим типы данных и использование памяти, чтобы понять, на что следует обратить внимание.

In [15]: ts.dtypes
Out[15]: 
id        int64
name     object
x       float64
y       float64
dtype: object
In [16]: ts.memory_usage(deep=True)  # memory usage in bytes
Out[16]: 
Index     8409608
id        8409608
name     65176434
x         8409608
y         8409608
dtype: int64

The name столбец занимает гораздо больше памяти, чем любой другой. У него всего несколько уникальных значений, поэтому он является хорошим кандидатом для преобразования в pandas.Categorical. С pandas.Categorical, мы храним каждое уникальное имя один раз и используем пространственно-эффективные целые числа, чтобы знать, какое конкретное имя используется в каждой строке.

In [17]: ts2 = ts.copy()

In [18]: ts2["name"] = ts2["name"].astype("category")

In [19]: ts2.memory_usage(deep=True)
Out[19]: 
Index    8409608
id       8409608
name     1051495
x        8409608
y        8409608
dtype: int64

Мы можем пойти немного дальше и понизить тип числовых столбцов до их наименьших типов используя pandas.to_numeric().

In [20]: ts2["id"] = pd.to_numeric(ts2["id"], downcast="unsigned")

In [21]: ts2[["x", "y"]] = ts2[["x", "y"]].apply(pd.to_numeric, downcast="float")

In [22]: ts2.dtypes
Out[22]: 
id        uint16
name    category
x        float32
y        float32
dtype: object
In [23]: ts2.memory_usage(deep=True)
Out[23]: 
Index    8409608
id       2102402
name     1051495
x        4204804
y        4204804
dtype: int64
In [24]: reduction = ts2.memory_usage(deep=True).sum() / ts.memory_usage(deep=True).sum()

In [25]: print(f"{reduction:0.2f}")
0.20

В целом, мы сократили объем памяти, занимаемый этим набором данных, до 1/5 от исходного размера.

См. Категориальные данные для получения дополнительной информации о pandas.Categorical и dtypes для обзора всех типов данных pandas.

Использовать чанкирование#

Некоторые рабочие нагрузки могут быть достигнуты с помощью чанкинга, разбивая большую задачу на множество маленьких. Например, преобразование отдельного CSV-файла в файл Parquet и повторение этого для каждого файла в директории. Пока каждый чанк помещается в память, вы можете работать с наборами данных, которые значительно больше памяти.

Примечание

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

Предположим, у нас есть ещё больший «логический набор данных» на диске, который представляет собой каталог файлов parquet. Каждый файл в каталоге представляет собой отдельный год всего набора данных.

In [26]: import pathlib

In [27]: N = 12

In [28]: starts = [f"20{i:>02d}-01-01" for i in range(N)]

In [29]: ends = [f"20{i:>02d}-12-13" for i in range(N)]

In [30]: pathlib.Path("data/timeseries").mkdir(exist_ok=True)

In [31]: for i, (start, end) in enumerate(zip(starts, ends)):
   ....:     ts = make_timeseries(start=start, end=end, freq="1min", seed=i)
   ....:     ts.to_parquet(f"data/timeseries/ts-{i:0>2d}.parquet")
   ....: 
data
└── timeseries
    ├── ts-00.parquet
    ├── ts-01.parquet
    ├── ts-02.parquet
    ├── ts-03.parquet
    ├── ts-04.parquet
    ├── ts-05.parquet
    ├── ts-06.parquet
    ├── ts-07.parquet
    ├── ts-08.parquet
    ├── ts-09.parquet
    ├── ts-10.parquet
    └── ts-11.parquet

Теперь мы реализуем внеядерный pandas.Series.value_counts(). Пиковое использование памяти в этом рабочем процессе — это самый большой чанк плюс небольшая серия, хранящая подсчёты уникальных значений до этого момента. Пока каждый отдельный файл помещается в память, это будет работать для наборов данных произвольного размера.

In [32]: %%time
   ....: files = pathlib.Path("data/timeseries/").glob("ts*.parquet")
   ....: counts = pd.Series(dtype=int)
   ....: for path in files:
   ....:     df = pd.read_parquet(path)
   ....:     counts = counts.add(df["name"].value_counts(), fill_value=0)
   ....: counts.astype(int)
   ....: 
CPU times: user 1.01 s, sys: 116 ms, total: 1.13 s
Wall time: 1.05 s
Out[32]: 
name
Alice      1994645
Bob        1993692
Charlie    1994875
dtype: int64

Некоторые читатели, такие как pandas.read_csv(), предлагают параметры для управления chunksize при чтении одного файла.

Ручное разбиение на блоки — приемлемый вариант для рабочих процессов, не требующих слишком сложных операций. Некоторые операции, такие как pandas.DataFrame.groupby(), значительно сложнее выполнить по частям. В таких случаях, возможно, лучше переключиться на другую библиотеку, которая реализует эти алгоритмы вне ядра для вас.

Использовать другие библиотеки#

Существуют другие библиотеки, которые предоставляют аналогичные API pandas и хорошо работают с DataFrame pandas, и могут дать вам возможность масштабировать обработку и анализ больших наборов данных с помощью параллельной среды выполнения, распределенной памяти, кластеризации и т.д. Вы можете найти больше информации в страница экосистемы.