Pythonで学ぶデータ整形

メモリフットプリントを最小化するPythonデータ整形:Pandasのデータ型最適化戦略

Tags: Python, Pandas, データクリーニング, メモリ最適化, データ型, 大規模データ

はじめに

データサイエンスやデータエンジニアリングの現場では、日々膨大な量のデータを取り扱います。特にPythonのPandasライブラリは、データの探索、整形、分析において非常に強力なツールですが、大規模なデータセットを扱う際には、メモリ使用量がボトルネックとなるケースが少なくありません。メモリフットプリントを最適化することは、処理速度の向上だけでなく、限られたリソース環境での実行可能性を確保するためにも不可欠です。

本記事では、PythonとPandasを用いたデータクリーニングの一環として、データ型の最適化に焦点を当てます。単なるダウンキャストに留まらず、Pandasの特定のデータ型(例:Categorical型、Nullable整数型)の深い活用、そして大規模データセットにおける実践的な戦略について、具体的なコード例とともに詳細に解説します。

1. データ型の基本とメモリ使用量の現状把握

Pandas DataFrameが消費するメモリは、主にその内部に含まれるデータの型によって決定されます。デフォルトでPandasは、数値データにはint64float64を、文字列や混合型のデータにはobject型(Pythonオブジェクトへの参照)を使用することが多く、これらはメモリ効率が良いとは限りません。

データセットのメモリフットプリントを把握する第一歩として、df.info(memory_usage='deep')df.memory_usage(deep=True)メソッドが有効です。特にdeep=Trueオプションは、object型カラムが参照する実際のPythonオブジェクト(文字列など)のメモリ使用量も正確に計算するため、より実態に近い値を得られます。

コード例:初期メモリ使用量の確認

まず、ダミーデータを用いて現状のメモリ使用量を確認します。

import pandas as pd
import numpy as np

# 大規模なダミーデータセットの生成
np.random.seed(42)
num_rows = 1_000_000
data = {
    'id': np.arange(num_rows),
    'category_str': np.random.choice(['A', 'B', 'C', 'D', 'E'], num_rows),
    'int_col': np.random.randint(0, 1000, num_rows),
    'float_col': np.random.rand(num_rows) * 1000,
    'bool_col': np.random.choice([True, False], num_rows),
    'large_str_col': ['long_string_value_' + str(i % 1000) for i in range(num_rows)]
}
df = pd.DataFrame(data)

# 欠損値の追加(例として一部のint_colとfloat_colにNaNを追加)
df.loc[df.sample(frac=0.05).index, 'int_col'] = np.nan
df.loc[df.sample(frac=0.03).index, 'float_col'] = np.nan

print("### 初期DataFrameのデータ型とメモリ使用量 ###")
print(df.info(memory_usage='deep'))
print("\nカラムごとの詳細なメモリ使用量:")
print(df.memory_usage(deep=True))

実行結果の例:

### 初期DataFrameのデータ型とメモリ使用量 ###
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1000000 entries, 0 to 999999
Data columns (total 6 columns):
 #   Column         Non-Null Count    Dtype
---  ------         --------------    -----
 0   id             1000000 non-null  int64
 1   category_str   1000000 non-null  object
 2   int_col        950000 non-null   float64
 3   float_col      970000 non-null   float64
 4   bool_col       1000000 non-null  bool
 5   large_str_col  1000000 non-null  object
dtypes: bool(1), float64(2), int64(1), object(2)
memory usage: 198.8 MB # これは深くないメモリ使用量
None

カラムごとの詳細なメモリ使用量:
Index                128
id               8000000
category_str    60000000 # object型が多くのメモリを消費
int_col          8000000
float_col        8000000
bool_col         1000000
large_str_col   66000000 # object型がさらに多くのメモリを消費
dtype: int64

この結果から、特にobject型であるcategory_strlarge_str_colが多くのメモリを消費していることが分かります。int_colNaNが含まれているため、Pandasは自動的にfloat64型として認識している点も注目に値します。

2. 数値型データの最適化:ダウンキャストとNullable整数型

数値型のカラムは、その値の範囲に応じて、よりメモリ効率の良いデータ型にダウンキャストすることが可能です。例えば、int64は8バイトを使用しますが、値が[-128, 127]の範囲に収まる場合はint8(1バイト)に変換できます。

| データ型 | 範囲 (符号付き) | バイト数 | | :-------- | :---------------------------------- | :------- | | int8 | -128 ~ 127 | 1 | | int16 | -32768 ~ 32767 | 2 | | int32 | -2147483648 ~ 2147483647 | 4 | | int64 | -9223372036854775808 ~ | 8 | | | 9223372036854775807 | | | float32 | 約 -3.4e383.4e38 (精度約7桁) | 4 | | float64 | 約 -1.8e3081.8e308 (精度約15桁)| 8 |

2.1. 標準的なダウンキャスト

pd.to_numeric()関数をdowncast引数と共に使用することで、数値型カラムを自動的に最もメモリ効率の良い型に変換できます。

# 数値型カラムのダウンキャスト
df_optimized = df.copy() # オリジナルを保持

# `id` カラムはユニークで、範囲が広いのでint32にできるか確認
print(f"id min: {df_optimized['id'].min()}, max: {df_optimized['id'].max()}")
# int_col はNaNがあるため、一旦float64のままダウンキャストを試みるか、
# Nullable整数型を検討する

# int_col と float_col にはNaNが含まれているため、標準のint型には変換できません。
# まず、float型をfloat32にダウンキャストします。
df_optimized['float_col'] = pd.to_numeric(df_optimized['float_col'], downcast='float')
df_optimized['id'] = pd.to_numeric(df_optimized['id'], downcast='integer') # idはint32になるはず

2.2. Nullable整数型の活用

Pandas 1.0以降で導入されたNull許容整数型(Int8, Int16, Int32, Int64)は、np.nanを保持できるint型であり、欠損値を含む整数カラムのメモリ最適化に非常に有効です。従来のint型はNaNを許容しないため、NaNが含まれると自動的にfloat64型に変換されていました。

# int_col を Nullable 整数型に変換
df_optimized['int_col'] = df_optimized['int_col'].astype('Int32') # または 'Int16', 'Int8'など適切なもの

print("\n### 数値型最適化後のDataFrameのデータ型とメモリ使用量 ###")
print(df_optimized.info(memory_usage='deep'))
print("\nカラムごとの詳細なメモリ使用量(数値型最適化後):")
print(df_optimized.memory_usage(deep=True))

実行結果の例:

### 数値型最適化後のDataFrameのデータ型とメモリ使用量 ###
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1000000 entries, 0 to 999999
Data columns (total 6 columns):
 #   Column         Non-Null Count    Dtype
---  ------         --------------    -----
 0   id             1000000 non-null  int32
 1   category_str   1000000 non-null  object
 2   int_col        950000 non-null   Int32 # Nullable整数型に変化
 3   float_col      970000 non-null   float32 # float32に変化
 4   bool_col       1000000 non-null  bool
 5   large_str_col  1000000 non-null  object
dtypes: Int32(1), bool(1), float32(1), int32(1), object(2)
memory usage: 147.5 MB
None

カラムごとの詳細なメモリ使用量(数値型最適化後):
Index                128
id               4000000 # int32に
category_str    60000000
int_col          4000000 # Int32に
float_col        4000000 # float32に
bool_col         1000000
large_str_col   66000000
dtype: int64

idint32に、int_colInt32に、float_colfloat32に変わり、それぞれのメモリ使用量が半減していることが確認できます。

3. カテゴリカル型データの活用

object型はPythonオブジェクトへの参照を格納するため、特に文字列が多い場合に大きなメモリを消費します。文字列が繰り返し出現する場合(カテゴリカルデータ)、Categorical型への変換は劇的なメモリ削減をもたらします。Categorical型は、ユニークな値(カテゴリ)を内部的に整数として保存し、その整数を実際のデータとして持つことで、メモリ効率を高めます。

コード例:文字列カラムのCategorical型への変換

category_strカラムとlarge_str_colカラムをCategorical型に変換します。

# category_str と large_str_col を Categorical 型に変換
df_optimized['category_str'] = df_optimized['category_str'].astype('category')
df_optimized['large_str_col'] = df_optimized['large_str_col'].astype('category')

print("\n### Categorical型最適化後のDataFrameのデータ型とメモリ使用量 ###")
print(df_optimized.info(memory_usage='deep'))
print("\nカラムごとの詳細なメモリ使用量(Categorical型最適化後):")
print(df_optimized.memory_usage(deep=True))

実行結果の例:

### Categorical型最適化後のDataFrameのデータ型とメモリ使用量 ###
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1000000 entries, 0 to 999999
Data columns (total 6 columns):
 #   Column         Non-Null Count    Dtype
---  ------         --------------    -----
 0   id             1000000 non-null  int32
 1   category_str   1000000 non-null  category # category型に変化
 2   int_col        950000 non-null   Int32
 3   float_col      970000 non-null   float32
 4   bool_col       1000000 non-null  bool
 5   large_str_col  1000000 non-null  category # category型に変化
dtypes: Int32(1), bool(1), category(2), float32(1), int32(1)
memory usage: 18.2 MB # 大幅に削減!
None

カラムごとの詳細なメモリ使用量(Categorical型最適化後):
Index                128
id               4000000
category_str      100236 # 大幅に削減
int_col          4000000
float_col        4000000
bool_col         1000000
large_str_col     102876 # 大幅に削減
dtype: int64

category_strlarge_str_colのメモリ使用量が劇的に削減されたことが確認できます。large_str_colは1000種類のユニークな値しか持たないため、Categorical型が非常に有効に機能しました。

注意点とメリット・デメリット:

4. ブール型と日付時刻型の最適化

4.1. ブール型の最適化

bool型はデフォルトで1バイトを使用するため、通常は特別な最適化は不要です。しかし、大規模データでbool型がobject型として格納されている場合などには、明示的にbool型に変換することでメモリを節約できます。

# ブール型は既に最適化されていることが多いですが、念のため
# df_optimized['bool_col'] = df_optimized['bool_col'].astype('bool') # すでにbool型

4.2. 日付時刻型の最適化

日付時刻データは、Pandasではdatetime64[ns]型として扱われます。この型はナノ秒精度でタイムスタンプを格納するため、8バイトを消費します。もしミリ秒や秒単位の精度で十分な場合は、datetime64[ms]datetime64[s]のように精度を落とすことでメモリを節約できる可能性があります。また、日付のみ(時間情報不要)であれば、datetime64[D]date型(Pandas 1.5.0以降)も検討できます。

# ダミーの日付時刻カラムを追加
num_rows = len(df_optimized)
start_date = pd.Timestamp('2020-01-01')
df_optimized['datetime_col'] = [start_date + pd.Timedelta(minutes=i) for i in range(num_rows)]

print("\n### 日付時刻型追加後のデータ型とメモリ使用量 ###")
print(df_optimized.info(memory_usage='deep'))

# 日付時刻型を最適化(例:秒単位精度で十分な場合)
# ただし、デフォルトのdatetime64[ns]は多くの場合、これ以上ダウンキャストできません。
# メモリをさらに削減したい場合は、Timestampを整数として保存し、必要に応じて変換するなどの
# 高度なアプローチが必要になることがあります。
# 例えば、もし日付のみで良いなら period[D] や date dtype を検討できます。
# df_optimized['datetime_col'] = df_optimized['datetime_col'].dt.date.astype('datetime64[D]') # これはエラーになる
# 正しいアプローチは、Pandasの拡張Dtypeとして導入されたPeriodDtypeなどを使用するか、
# または日付のみを抽出し、文字列や数値で保持する方法が考えられます。
# しかし、標準的なdatetime64[ns]は多くの用途でバランスが取れています。
# より高度なメモリ最適化では、日付をUnixタイムスタンプの整数として保存し、
# 読み込み時にdatetime型に変換するなどの手法が用いられます。

# 今回は、datetime64[ns]で十分と判断し、これ以上のダウンキャストは行わないものとします。

datetime64[ns]は8バイト固定ですが、日付時刻データは通常、その粒度で処理されることが多いため、無理なダウンキャストはデータの正確性を損なう可能性があります。

5. 効率的なデータ型自動推定と一括変換

個別のカラムに対して手動で型変換を行うのは手間がかかります。Pandasには、データ型を自動的に推論し、変換する便利なメソッドが用意されています。

5.1. df.infer_objects()

infer_objects()メソッドは、object型カラムに対して、より具体的なNumPy dtype(int, float, boolなど)に変換できるものがあれば、それらを推論して変換します。文字列からdatetime型への変換は行いません。

# df_optimized はすでに最適な型に変換済みのため、今回は元のdfで試します。
df_original_copy = df.copy()

# object型で構成されたカラムをより適切な型に変換
# この例ではすでに'category_str'や'large_str_col'がobject型ですが、
# これらはinfer_objectsでは自動的にcategoryにはなりません。
# 主に数値的な文字列やブール値の文字列を対象とします。
df_original_copy['int_as_str'] = df_original_copy['int_col'].astype(str)
df_original_copy['bool_as_str'] = df_original_copy['bool_col'].astype(str)
df_original_copy['int_as_str'] = df_original_copy['int_as_str'].replace('nan', np.nan) # NaNは残す
df_original_copy['bool_as_str'] = df_original_copy['bool_as_str'].replace('False', 'false').replace('True', 'true') # 小文字に統一

print("\n### infer_objects適用前のデータ型 ###")
print(df_original_copy[['int_as_str', 'bool_as_str']].dtypes)

df_original_copy = df_original_copy.infer_objects()

print("\n### infer_objects適用後のデータ型 ###")
print(df_original_copy[['int_as_str', 'bool_as_str']].dtypes)

実行結果の例:

### infer_objects適用前のデータ型 ###
int_as_str     object
bool_as_str    object
dtype: object

### infer_objects適用後のデータ型 ###
int_as_str    float64 # NaNがあるためfloat64になる
bool_as_str      bool
dtype: object

int_as_strはNaNがあるためfloat64に、bool_as_strboolに変換されたことが分かります。

5.2. df.convert_dtypes() (Pandas 1.0以降)

convert_dtypes()メソッドは、Pandasが提供するNull許容型(Int64, Float64, Boolean, string, datetime64[ns, tz])に変換できるカラムを自動的に変換します。object型からstring型への変換や、float64からInt64(欠損値がある場合でも)への変換が可能です。これは、データクリーニングとメモリ最適化の両方を考慮した非常に強力な機能です。

# 再度、元のDataFrameから開始し、convert_dtypesを適用します。
df_converted = df.copy()

print("\n### convert_dtypes適用前のデータ型とメモリ使用量 ###")
print(df_converted.info(memory_usage='deep'))

df_converted = df_converted.convert_dtypes()

print("\n### convert_dtypes適用後のデータ型とメモリ使用量 ###")
print(df_converted.info(memory_usage='deep'))
print("\nカラムごとの詳細なメモリ使用量(convert_dtypes適用後):")
print(df_converted.memory_usage(deep=True))

実行結果の例:

### convert_dtypes適用前のデータ型とメモリ使用量 ###
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1000000 entries, 0 to 999999
Data columns (total 6 columns):
 #   Column         Non-Null Count    Dtype
---  ------         --------------    -----
 0   id             1000000 non-null  int64
 1   category_str   1000000 non-null  object
 2   int_col        950000 non-null   float64
 3   float_col      970000 non-null   float64
 4   bool_col       1000000 non-null  bool
 5   large_str_col  1000000 non-null  object
dtypes: bool(1), float64(2), int64(1), object(2)
memory usage: 198.8 MB
None

### convert_dtypes適用後のデータ型とメモリ使用量 ###
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1000000 entries, 0 to 999999
Data columns (total 6 columns):
 #   Column         Non-Null Count    Dtype
---  ------         --------------    -----
 0   id             1000000 non-null  Int64 # Nullable整数型に変化
 1   category_str   1000000 non-null  string # string型に変化
 2   int_col        950000 non-null   Int64 # Nullable整数型に変化
 3   float_col      970000 non-null   Float64 # Nullable浮動小数点型に変化
 4   bool_col       1000000 non-null  boolean # Nullableブール型に変化
 5   large_str_col  1000000 non-null  string # string型に変化
dtypes: Boolean(1), Float64(1), Int64(2), string(2)
memory usage: 228.9 MB
None

カラムごとの詳細なメモリ使用量(convert_dtypes適用後):(注意: string型はobjectよりメモリを食うことも)
Index                128
id               8000000
category_str    60000000 # string Dtype は object Dtype と同等かそれ以上のメモリを消費することがあります。
int_col          8000000
float_col        8000000
bool_col         1000000
large_str_col   66000000
dtype: int64

convert_dtypes()はNull許容型に変換してくれますが、string型はobject型と比べてメモリ効率が必ずしも良いわけではありません。特に、ユニークな値が少ないカテゴリカルデータの場合、Categorical型への明示的な変換が依然として最も効果的なメモリ最適化手法となります。

したがって、convert_dtypes()はデータ型の一貫性を保ち、欠損値を扱う上では非常に有用ですが、メモリ最適化を最優先する大規模データセットでは、Categorical型への手動変換と組み合わせることが重要です。

6. 実践的考慮事項とエッジケース

6.1. データ型変換による情報損失のリスク

6.2. 混合型カラムへの対応

データソースによっては、1つのカラムに数値、文字列、ブール値が混在していることがあります。Pandasはこのようなカラムをobject型としてロードします。無理に単一の数値型やカテゴリカル型に変換しようとすると、エラーが発生したり、データがNaNになる可能性があります。

戦略: 1. クリーニングと分離: 混在している値を特定し、適切な型に変換できるようクリーニングするか、必要であれば複数のカラムに分離します。 2. object型を維持: 混在が許容される、またはクリーニングコストが高い場合は、object型のまま維持することも選択肢の一つです。ただし、メモリ効率は悪くなります。

6.3. I/O処理との連携と自動化

メモリ最適化されたDataFrameは、ParquetやFeatherのようなバイナリ形式で保存することで、ディスクI/Oの高速化とファイルサイズの削減も実現できます。これらの形式は、Pandasのデータ型情報を保持できるため、ロード時にも最適化されたデータ型を維持しやすいというメリットがあります。

# 最適化されたDataFrameをParquet形式で保存
output_path = 'optimized_data.parquet'
df_optimized.to_parquet(output_path, index=False)
print(f"\n最適化されたDataFrameを {output_path} に保存しました。")

# 保存したデータロード時のメモリ使用量を確認
df_loaded = pd.read_parquet(output_path)
print("\n### Parquetからロード後のデータ型とメモリ使用量 ###")
print(df_loaded.info(memory_usage='deep'))
print("\nカラムごとの詳細なメモリ使用量(Parquetからロード後):")
print(df_loaded.memory_usage(deep=True))

実行結果の例:

最適化されたDataFrameを optimized_data.parquet に保存しました。

### Parquetからロード後のデータ型とメモリ使用量 ###
<class 'pandas.core.frame.DataFrame.pd.read_parquet'>
RangeIndex: 1000000 entries, 0 to 999999
Data columns (total 7 columns):
 #   Column          Non-Null Count    Dtype
---  ------          --------------    -----
 0   id              1000000 non-null  int32
 1   category_str    1000000 non-null  category
 2   int_col         950000 non-null   Int32
 3   float_col       970000 non-null   float32
 4   bool_col        1000000 non-null  bool
 5   large_str_col   1000000 non-null  category
 6   datetime_col    1000000 non-null  datetime64[ns]
dtypes: Int32(1), bool(1), category(2), datetime64[ns](1), float32(1), int32(1)
memory usage: 22.1 MB
None

カラムごとの詳細なメモリ使用量(Parquetからロード後):
Index                128
id               4000000
category_str      100236
int_col          4000000
float_col        4000000
bool_col         1000000
large_str_col     102876
datetime_col     8000000
dtype: int64

Parquet形式で保存し、再度ロードしても、最適化されたデータ型が維持され、メモリ使用量が大幅に削減されていることが確認できます。

データクリーニングパイプラインにデータ型最適化ステップを組み込むことで、大規模データ処理のパフォーマンスとスケーラビリティを向上させることができます。

まとめ

本記事では、PythonとPandasを用いた大規模データセットのメモリフットプリントを最小化するためのデータ型最適化戦略について深く掘り下げました。

データクリーニングのプロセスにおいて、データ型最適化は単なるメモリ削減以上の価値をもたらします。処理速度の向上、計算リソースの効率的な利用、そしてより大規模なデータセットへの対応能力を高めることで、データ分析や機械学習プロジェクトの成功に大きく貢献するでしょう。