Масштабирование до больших наборов данных#
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, и могут дать вам возможность масштабировать обработку и анализ больших наборов данных с помощью параллельной среды выполнения, распределенной памяти, кластеризации и т.д. Вы можете найти больше информации в страница экосистемы.