Масштабирование для больших наборов данных
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 в данном случае использует примерно в 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
Столбец 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 и типы для общего обзора всех типов данных 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 760 ms, sys: 26.1 ms, total: 786 ms
Wall time: 559 ms
Out[32]:
name
Alice 1994645
Bob 1993692
Charlie 1994875
dtype: int64
Некоторые читатели, например pandas.read_csv(), предлагают параметры для управления chunksize при чтении одного файла.
Ручное чанкирование — это приемлемый вариант для рабочих процессов, которые не требуют слишком сложных операций. Некоторые операции, такие как pandas.DataFrame.groupby(), намного сложнее выполнять в режиме чанков. В таких случаях вам может быть лучше переключиться на другую библиотеку, которая реализует эти внеоператорные алгоритмы за вас.
Использование других библиотек
Существуют другие библиотеки, которые предоставляют аналогичные API pandas и хорошо взаимодействуют с pandas DataFrame, а также могут предоставить вам возможность масштабирования обработки и анализа больших наборов данных с помощью параллельного выполнения, распределенной памяти, кластеризации и т. д. Дополнительную информацию можно найти на странице экосистемы экосистемы.
© 2008–2022, AQR Capital Management, LLC, Lambda Foundry, Inc. and PyData Development Team
Licensed under the 3-clause BSD License.
https://pandas.pydata.org/pandas-docs/version/2.2.2/user_guide/scale.html