はじめに
本記事では、プライバシーを保護しながら実用的な合成データを生成するための手法を、具体的な実践を通じて解説します。特に、合成データ生成ライブラリ synthcity に実装されているADS-GAN(Adversarial De-identification Synthetic GAN)に焦点を当て、その利用方法から評価までを一貫してご紹介します。
AIの進化に伴い、データ活用はあらゆる分野で不可欠となっていますが、同時に個人情報保護の重要性も増しています。このジレンマを解決する鍵の一つが「合成データ」です。合成データは、元のデータの統計的特性を保ちつつ、個人の特定リスクを低減できるため、プライバシーに配慮したデータ分析やモデル開発に大きく貢献します。
この記事では、UCI Adult Census Incomeデータセットを例に、sex と race をセンシティブ属性として指定し、ADS-GANで合成データを生成します。そして、生成されたデータが元のデータの分布をどれだけ保っているか(分布比較)、実データを用いた分析タスクにおいてどの程度の有用性を持つか(ユーティリティ評価)、そしてどの程度プライバシーが保護されているか(簡易プライバシー評価)を包括的に検証します。
対象読者:
- 合成データ生成技術に関心のあるデータサイエンティストや研究者
- プライバシー保護とデータ活用のバランスを模索している開発者
synthcityライブラリのADS-GANを実践的に使ってみたい方- 合成データの評価指標について理解を深めたい方
記事のポイント:
synthcityのADS-GANを用いた合成データ生成の具体的な手順を学べます。- 合成データの「分布比較」「ユーティリティ評価」「簡易プライバシー評価」の一連の流れを実践的に体験できます。
- ADS-GANの重要なパラメータである
lambda_identifiability_penaltyが、プライバシーとユーティリティのトレードオフに与える影響を深く理解できます。 - 実データ分析における合成データの可能性と課題について考察を深められます。
セットアップ
本記事で紹介するコードを実行するには、以下のライブラリをインストールする必要があります。
pip install "synthcity[all]" scikit-learn pandas numpy
データセットとセンシティブ列
本記事では、広く利用されているUCI Adult Census Incomeデータセットを使用します。このデータセットは、個人の属性(年齢、教育、職業など)と年収(<=50K または >50K)を記録しており、収入予測のベンチマークとしてよく用いられます。
- データ: UCI Adult Census Income(
sklearn.datasets.fetch_openml("adult", version=2, as_frame=True)) - 目的変数:
income(<=50K/>50K) - センシティブ属性:
sex,race
これらのセンシティブ属性は、合成データ生成時にプライバシー保護の対象とします。
コードの抜粋
それでは、実際にsynthcityライブラリを使って合成データを生成するプロセスを見ていきましょう。以下は、記事の主要なステップを抜粋したコードです。実行可能な完全版は、記事末尾の script.py を参照してください。
from sklearn.datasets import fetch_openml
from synthcity.plugins import Plugins
from synthcity.plugins.core.dataloader import GenericDataLoader
# 1) Adult 読み込み
adult = fetch_openml("adult", version=2, as_frame=True)
X = adult.data.copy()
y = adult.target.copy()
X["income"] = y
# 2) DataLoader(センシティブ: sex, race)
loader = GenericDataLoader(
data=X,
target_column="income",
sensitive_features=["sex", "race"],
)
# 3) ADS-GAN 取得・学習
plugins = Plugins(categories=["generic", "privacy"])
adsgan = plugins.get(
"adsgan",
n_iter=1000,
lambda_identifiability_penalty=0.1,
)
adsgan.fit(loader)
# 4) 合成
syn_loader = adsgan.generate(count=5000)
syn_df = syn_loader.dataframe()
このコードでは、まずAdultデータセットを読み込み、目的変数incomeを結合します。次に、synthcityのGenericDataLoaderを使ってデータをsynthcityが扱える形式に変換し、目的変数とセンシティブ属性を指定します。ADS-GANモデルを取得し、実データで学習を行った後、指定した件数(5000件)の合成データを生成しています。
合成データの評価
合成データが元のデータの特性をどれだけ保持しているか、そしてどれだけプライバシーが保護されているかを評価することは非常に重要です。ここでは、以下の3つの側面から合成データを評価します。
分布比較(sex / race)
合成データの品質評価の第一歩として、重要な特徴量、特にセンシティブ属性の周辺分布が元のデータとどれだけ近いかを比較します。ここでは、カテゴリカルなsexとraceの各カテゴリに属するサンプルの割合を確認します。
この比較により、合成データがセンシティブ属性の偏りや割合を適切に再現できているか、あるいは特定のカテゴリが過剰に生成されたり、欠落したりしていないかをざっと確認することができます。分布が大きく異なる場合、合成データは元のデータの特性を十分に捉えられていない可能性を示唆します。
ユーティリティ評価(実→実 vs 合成→実)
合成データの「ユーティリティ(有用性)」は、それが実データと同じように分析やモデル学習に利用できるかを測る指標です。ここでは、機械学習モデル(ロジスティック回帰)を用いて、以下の2つのシナリオで予測性能を比較します。
- 実データで学習 → 実データで評価: これはモデル性能の「上限ベースライン」となり、合成データがどれだけこのベースラインに近づけるかを測る基準となります。
- 合成データで学習 → 実データで評価: これが合成データの「実用度」を示します。合成データで学習したモデルが、実データに対してどれほどの予測精度を達成できるかを確認します。
Accuracy(正解率)とROC-AUC(受信者操作特性曲線下面積)という主要な分類タスクの評価指標を用い、両シナリオでの落ち幅を比較します。この差が小さいほど、合成データが元のデータと同様に有用であると解釈できます。
簡易プライバシー評価(最近傍距離)
プライバシー評価は、合成データが元の個人の情報をどの程度保護しているかを確認するために重要です。ここでは、「最近傍距離」に基づく簡易的な評価手法を用います。
具体的には、実データと合成データを同じ前処理空間(例: 特徴量スケーリングなど)に写像した後、合成データセット内の各サンプルについて、実データセット中の最も近いサンプル(最近傍)との距離を計算します。
もし極端に小さい距離を持つ合成サンプルが多数見つかった場合、それは実データの「ほぼ複製」に近い情報が合成データに混入している懸念を示唆します。これは、元の個人を特定する「再識別リスク」が高まることを意味します。この手法は厳密なプライバシー保証(例: 差分プライバシーのような数学的保証)を与えるものではありませんが、合成データのプライバシーリスクを直感的に把握するための一つの目安として有効です。
実行方法
本記事で紹介した内容を完全に実行できるスクリプト script.py は、記事末尾に添付しています。以下のコマンドを実行することで、一連のプロセスと評価結果を確認できます。
python script.py
スクリプトを実行すると、以下のような主要な出力が得られます。
- 合成データの先頭5行
sexとraceの分布比較(実データ vs 合成データ)- ユーティリティ評価(Accuracy / ROC-AUC の比較)
- 簡易プライバシー評価(最近傍距離の要約統計量と極小距離の割合)
これらの出力は、合成データの品質とプライバシー保護のレベルを総合的に判断するための貴重な情報となります。
lambda_identifiability_penaltyとは?その効果とバランス
ADS-GANの学習において、lambda_identifiability_penaltyは非常に重要なハイパーパラメータです。これは、ADS-GANの損失関数に組み込まれている識別可能性ペナルティ項の重みを決定します。このペナルティ項は、生成された合成データが元のデータと統計的に類似しつつも、個人の再識別リスクを低減するようにモデルを誘導する役割を果たします。
この値の調整は、合成データの「ユーティリティ」と「プライバシー」のトレードオフに直接影響します。
高い値(目安として 1.0~)の場合
lambda_identifiability_penaltyの値を高く設定すると、モデルはプライバシー保護をより強く意識して合成データを生成します。
- プライバシー保護の強化: 個人を特定できる可能性(re-identification risk)がより強く抑制されます。合成データから元の個人を特定しにくくなり、プライバシーリスクが低減されます。
- ユーティリティとのトレードオフ: プライバシー保護を優先する分、生成されるデータの統計的特性が元のデータから乖離する可能性が高まります。結果として、合成データで学習したモデルの性能(ユーティリティ)が低下することがあります。
- 分布の変化: 元データの分布を完全に再現することよりも、識別可能性を下げる方向に生成されるため、センシティブ属性や他の特徴量の分布が元データからずれる傾向が見られることがあります。
低い値(目安として ~1.0)の場合
lambda_identifiability_penaltyの値を低く設定すると、モデルはデータの有用性(ユーティリティ)を優先して合成データを生成します。
- ユーティリティの重視: 生成データの品質が高く、実データに近い分布や特徴を保持しやすくなります。実データで学習したモデルに近い性能を期待できる場合があります。
- プライバシーリスクの増加: 識別可能性の抑制が弱いため、合成データから元の個人を特定されるリスクが高くなる可能性があります。
バランスの取り方
実務においては、lambda_identifiability_penaltyの最適な値は、データセットの特性や合成データの利用目的によって異なります。
- プライバシーが最優先の場合: 1.0〜3.0 程度の高めの値を検討し、再識別リスクを最小限に抑えます。
- ユーティリティとプライバシーのバランスを重視する場合: 0.3〜1.0 程度の値を設定し、両者のバランスを探ります。
- ユーティリティ優先の場合: 0.1 以下の低めの値を設定し、データの有用性を最大化します。
重要なのは、これらの値が「魔法の数字」ではないということです。実際にさまざまな値を試行し、ユーティリティ評価、分布比較、プライバシー評価といった複数の指標を確認しながら、ご自身の目的に合った最適なバランス点を見つけることが推奨されます。
実験結果とその考察
本記事では、ADS-GANのlambda_identifiability_penaltyが合成データの品質に与える影響を詳細に検証するため、n_iter=50 に固定し、lambda_identifiability_penalty を [0.3, 1.0, 3.0] の3段階で比較実験を行いました。以下に、その結果とそこから導かれる考察を示します。
ユーティリティ評価結果
実データで学習したモデル(real→real)をベースラインとし、各lambda_identifiability_penaltyで生成された合成データで学習したモデル(synth→real)の予測性能を比較しました。結果は以下の表にまとめられます。
| lambda_identifiability_penalty | Accuracy (real→real) | ROC-AUC (real→real) | Accuracy (synth→real) | ROC-AUC (synth→real) |
|---|---|---|---|---|
| 0.3 | 0.8535 | 0.9061 | 0.8224 | 0.8685 |
| 1.0 | 0.8535 | 0.9061 | 0.8118 | 0.8740 |
| 3.0 | 0.8535 | 0.9061 | 0.7285 | 0.3758 |
結果の解釈:
- λ=0.3: Accuracyは約0.031ポイント、ROC-AUCは約0.038ポイント低下しました。これはベースラインと比較して比較的良好なユーティリティを維持していると言えます。
- λ=1.0: Accuracyは約0.042ポイント、ROC-AUCは約0.032ポイント低下しました。λ=0.3の場合とほぼ同等の性能を維持できており、プライバシー保護をやや強化してもユーティリティへの影響は限定的でした。
- λ=3.0: Accuracyは約0.125ポイント、ROC-AUCは驚くべきことに約0.530ポイントもの大幅な低下を示しました。特にROC-AUCが0.3758という低い値になったことは、この設定では合成データがもはや有用な情報を含んでいないことを強く示唆しています。これは、プライバシー保護を極端に優先した結果、生成データの品質が大きく損なわれた典型的な例です。

分布比較結果
次に、センシティブ属性であるsexとraceの分布について、実データと各lambda_identifiability_penaltyで生成された合成データを比較しました。


結果の解釈:
- λ=0.3, 1.0: これらの設定では、実データの
sexおよびraceの周辺分布を比較的よく再現していることがグラフから見て取れます。合成データが元のデータの基本的な特性を捉えていることを示します。 - λ=3.0: 一方で、λ=3.0の場合、分布が実データから大きくずれる傾向が見られます。これは、プライバシー保護を最優先した結果、元データの分布を正確に再現することよりも、識別可能性を下げる方向にデータが変形されたためと考えられます。
最近傍距離によるプライバシー評価
最後に、合成サンプルから実サンプルへの最近傍距離の分布を比較し、プライバシーリスクの傾向を確認しました。

結果の解釈:
- λ=0.3, 1.0: 距離分布は比較的近い範囲に集中しており、特定の合成サンプルが実データと完全に一致するような極端に小さい距離のケースは少ない可能性を示唆します。
- λ=3.0: この設定では、距離分布がより広がり、実データから十分に離れたサンプルが多く生成されている可能性が考えられます。これは、プライバシー保護が強化された結果、再識別リスクが低減されていることを示唆する一方で、分布の歪み(前述)も伴うことになります。
考察
この一連の実験結果から、以下の重要な知見が得られました。
- ユーティリティとプライバシーのトレードオフの明確化:
lambda_identifiability_penaltyの値が高いほど、プライバシー保護が強化される一方で、ユーティリティ(特にROC-AUC)が著しく低下する傾向が明確に確認できました。これは、合成データ生成における「プライバシー・ユーティリティ・トレードオフ」の典型的な例です。 - 実用的なバランス点の模索: 本実験では、λ=0.3〜1.0の範囲で、ユーティリティを比較的良好に維持しながら、ある程度のプライバシー保護も実現できる可能性が示唆されました。この範囲で、目的や許容リスクに応じた最適な値を見つけることが実務においては重要です。
- 用途に応じた戦略的選択: 合成データの利用目的が、探索的データ分析やモデルの初期学習など「ユーティリティ優先」であれば低いλ値を、機密性の高いデータ共有や公開など「プライバシー最優先」であれば高いλ値を検討するなど、用途に応じた戦略的なパラメータ選択が求められます。
これらの結果は、ADS-GANのようなプライバシー保護に特化した合成データ生成モデルを運用する際に、lambda_identifiability_penaltyの調整がいかに重要であるかを浮き彫りにしています。
調整のヒント
合成データの品質と実用性をさらに向上させるために、いくつかの調整ポイントと今後の展望をご紹介します。
n_iter(学習イテレーション数): 本記事では実験の都合上50に固定しましたが、通常はより高い品質を求める場合、1000以上の学習イテレーションを検討してください。学習時間を増やせば、モデルがデータの複雑なパターンをより正確に学習し、高品質な合成データを生成できる可能性が高まります。lambda_identifiability_penalty: 上記の実験結果を参照し、用途に応じて0.3〜3.0の範囲で調整してください。ユーティリティとプライバシーの間のバランスを、具体的な評価指標(Accuracy、ROC-AUC、分布比較、最近傍距離など)を確認しながら見つけることが肝要です。本実験では、λ=0.3〜1.0の範囲で比較的良好なバランスが得られました。- 前処理・モデル: ユーティリティ評価に用いる分類器を、より複雑なXGBoostやLightGBMなどの勾配ブースティングモデルに変更することで、合成データの性能限界をより正確に測ることができます。また、合成データ生成前の実データの前処理(カテゴリ特徴量のエンコーディング、数値特徴量のスケーリングなど)も合成データの品質に大きく影響するため、慎重に検討する価値があります。
- その他のプライバシー指標: 最近傍距離は簡易的な指標ですが、より厳密なプライバシー保証を評価するために、メンバーシップ推論攻撃(Membership Inference Attack)や属性推論攻撃(Attribute Inference Attack)など、より高度なプライバシー攻撃シミュレーションを行うことも検討できます。
- 公平性(Fairness)評価: 本記事では
sexやraceをセンシティブ属性としてプライバシーの観点から扱いましたが、これらの属性に基づくモデルの公平性(例: 各属性グループ間での予測性能の差)を評価することも、合成データ活用の重要な側面です。合成データが元のデータの公平性特性をどれだけ維持しているか、または改善しているかを確認する視点も有用です。
合成データ技術は急速に進化しており、プライバシー保護とデータ活用の両立を目指す上で不可欠なツールとなりつつあります。本記事が、その実践的な理解の一助となれば幸いです。
script.py
from sklearn.datasets import fetch_openml
from synthcity.plugins import Plugins
from synthcity.plugins.core.dataloader import GenericDataLoader
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, roc_auc_score
from sklearn.neighbors import NearestNeighbors
import os
import json
from joblib import dump
import pickle
import matplotlib.pyplot as plt
def binarize_income(series: pd.Series) -> np.ndarray:
return (series.astype(str).str.contains(">50K")).astype(int).values
def split_num_cat(df: pd.DataFrame, cols):
num_cols, cat_cols = [], []
for c in cols:
if pd.api.types.is_numeric_dtype(df[c]):
num_cols.append(c)
else:
cat_cols.append(c)
return num_cols, cat_cols
def main():
# 1) Adult データ読み込み
adult = fetch_openml("adult", version=2, as_frame=True)
X = adult.data.copy()
y = adult.target.copy()
X["income"] = y
# 2) GenericDataLoader(センシティブ列: sex, race)
loader = GenericDataLoader(
data=X,
target_column="income",
sensitive_features=["sex", "race"],
)
# 出力先
models_dir = os.path.join(os.path.dirname(__file__), "models")
outputs_dir = os.path.join(os.path.dirname(__file__), "outputs")
os.makedirs(models_dir, exist_ok=True)
os.makedirs(outputs_dir, exist_ok=True)
# 分布表示: real のみ事前表示
def show_norm_counts(title, s: pd.Series):
print(title)
print(s.value_counts(normalize=True, dropna=False))
print("\n=== センシティブ列の分布(real)===")
show_norm_counts("元データ sex:", X["sex"])
show_norm_counts("\n元データ race:", X["race"])
# 可視化: sex / race 分布バー作成関数(後で4系列: Real, Synth100/300/1000)
def save_dist_bar(real_s: pd.Series, syn_s: pd.Series, title: str, out_path: str):
real_p = real_s.value_counts(normalize=True, dropna=False)
syn_p = syn_s.value_counts(normalize=True, dropna=False)
cats = sorted(set(real_p.index).union(set(syn_p.index)), key=lambda x: str(x))
real_vals = [real_p.get(c, 0.0) for c in cats]
syn_vals = [syn_p.get(c, 0.0) for c in cats]
x = range(len(cats))
width = 0.38
plt.figure(figsize=(7, 4))
plt.bar([i - width/2 for i in x], real_vals, width=width, label="real")
plt.bar([i + width/2 for i in x], syn_vals, width=width, label="synthetic")
plt.xticks(list(x), [str(c) for c in cats], rotation=20)
plt.ylabel("proportion")
plt.title(title)
plt.legend()
plt.tight_layout()
plt.savefig(out_path, dpi=150)
plt.close()
# ユーティリティ評価のための共通準備
target_col = "income"
feature_cols = [c for c in X.columns if c != target_col]
real_train, real_test = train_test_split(
X, test_size=0.25, random_state=42, stratify=X[target_col]
)
y_real_train = binarize_income(real_train[target_col])
y_real_test = binarize_income(real_test[target_col])
num_cols, cat_cols = split_num_cat(X, feature_cols)
preprocessor = ColumnTransformer(
transformers=[
("num", StandardScaler(), num_cols),
("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), cat_cols),
]
)
pipe_real = Pipeline([( "prep", preprocessor ), ( "clf", LogisticRegression(max_iter=1000) )])
pipe_real.fit(real_train[feature_cols], y_real_train)
proba_RR = pipe_real.predict_proba(real_test[feature_cols])[:, 1]
acc_real = accuracy_score(y_real_test, (proba_RR >= 0.5).astype(int))
auc_real = roc_auc_score(y_real_test, proba_RR)
lambda_list = [0.3, 1.0, 3.0]
n_iter = 30 # 固定
synth_map = {} # lambda -> syn_df
metrics_map = {} # lambda -> (acc, auc)
dist_map = {} # lambda -> distances array
for lam in lambda_list:
plugins = Plugins(categories=["generic", "privacy"])
adsgan = plugins.get("adsgan", n_iter=n_iter, lambda_identifiability_penalty=lam)
adsgan.fit(loader)
syn_loader = adsgan.generate(count=5000)
syn_df_lam = syn_loader.dataframe()
synth_map[lam] = syn_df_lam
# 合成→実のユーティリティ
pipe_syn = Pipeline([( "prep", preprocessor ), ( "clf", LogisticRegression(max_iter=1000) )])
y_syn_train = binarize_income(syn_df_lam[target_col])
pipe_syn.fit(syn_df_lam[feature_cols], y_syn_train)
proba_SR = pipe_syn.predict_proba(real_test[feature_cols])[:, 1]
acc_B = accuracy_score(y_real_test, (proba_SR >= 0.5).astype(int))
auc_B = roc_auc_score(y_real_test, proba_SR)
metrics_map[lam] = (acc_B, auc_B)
# 近傍距離
prep_only = preprocessor
prep_only.fit(real_train[feature_cols])
Z_real = prep_only.transform(real_train[feature_cols])
Z_syn = prep_only.transform(syn_df_lam[feature_cols])
nn = NearestNeighbors(n_neighbors=1, metric="euclidean")
nn.fit(Z_real)
distances, _ = nn.kneighbors(Z_syn, return_distance=True)
dist_map[lam] = distances.ravel()
# モデル保存(blob)と個別ファイル保存
lam_str = str(lam).replace(".", "p")
adsgan_blob = adsgan.save()
with open(os.path.join(models_dir, f"adsgan_lambda{lam_str}.pkl"), "wb") as f:
pickle.dump(adsgan_blob, f)
syn_df_lam.to_csv(os.path.join(outputs_dir, f"synthetic_sample_lambda{lam_str}.csv"), index=False)
with open(os.path.join(outputs_dir, f"metrics_lambda{lam_str}.json"), "w", encoding="utf-8") as f:
json.dump({
"n_iter": n_iter,
"lambda_identifiability_penalty": float(lam),
"accuracy_real_to_real": float(acc_real),
"roc_auc_real_to_real": float(auc_real),
"accuracy_synth_to_real": float(acc_B),
"roc_auc_synth_to_real": float(auc_B),
}, f, ensure_ascii=False, indent=2)
# まとめCSV
summary = []
for lam in lambda_list:
acc_B, auc_B = metrics_map[lam]
summary.append({
"lambda_identifiability_penalty": float(lam),
"n_iter": n_iter,
"accuracy_real_to_real": float(acc_real),
"roc_auc_real_to_real": float(auc_real),
"accuracy_synth_to_real": float(acc_B),
"roc_auc_synth_to_real": float(auc_B),
})
pd.DataFrame(summary).to_csv(os.path.join(outputs_dir, "summary_metrics.csv"), index=False)
# 4系列を1枚に:sex / race の分布バー
def save_dist_4bars(real_s: pd.Series, syn_map: dict, colname: str, out_path: str):
cats = sorted(set(real_s.unique()))
for lam, df_lam in syn_map.items():
cats = sorted(set(cats).union(set(df_lam[colname].unique())), key=lambda x: str(x))
x = np.arange(len(cats))
width = 0.2
plt.figure(figsize=(8, 4))
# real
real_p = real_s.value_counts(normalize=True, dropna=False)
plt.bar(x - 1.5*width, [real_p.get(c,0.0) for c in cats], width=width, label="real")
# synths
for idx, lam in enumerate(lambda_list):
syn_p = synth_map[lam][colname].value_counts(normalize=True, dropna=False)
plt.bar(x - 0.5*width + idx*width, [syn_p.get(c,0.0) for c in cats], width=width, label=f"λ={lam}")
plt.xticks(list(x), [str(c) for c in cats], rotation=20)
plt.ylabel("proportion")
plt.title(f"{colname} distribution (real vs λ={lambda_list})")
plt.legend()
plt.tight_layout()
plt.savefig(out_path, dpi=150)
plt.close()
save_dist_4bars(X["sex"], synth_map, "sex", os.path.join(outputs_dir, "dist_sex_all.png"))
save_dist_4bars(X["race"], synth_map, "race", os.path.join(outputs_dir, "dist_race_all.png"))
# ユーティリティ比較を1枚に
plt.figure(figsize=(7,4))
metrics = ["Accuracy", "ROC-AUC"]
x = np.arange(len(metrics))
width = 0.2
plt.bar(x - 1.5*width, [acc_real, auc_real], width=width, label="real→real")
for idx, lam in enumerate(lambda_list):
acc_B, auc_B = metrics_map[lam]
plt.bar(x - 0.5*width + idx*width, [acc_B, auc_B], width=width, label=f"λ={lam}→real")
plt.xticks(list(x), metrics)
plt.ylim(0, 1.0)
plt.title(f"Utility comparison (real vs λ={lambda_list})")
plt.legend()
plt.tight_layout()
plt.savefig(os.path.join(outputs_dir, "utility_metrics_all.png"), dpi=150)
plt.close()
# NN距離ヒスト重ね描き
plt.figure(figsize=(7,4))
colors = {lam: color for lam, color in zip(lambda_list, ["#4e79a7", "#f28e2b", "#e15759"])}
for lam in lambda_list:
plt.hist(dist_map[lam], bins=60, alpha=0.4, label=f"λ={lam}", color=colors[lam], density=True)
plt.xlabel("nearest neighbor distance (synthetic → real)")
plt.ylabel("density")
plt.title(f"NN distance (λ={lambda_list})")
plt.legend()
plt.tight_layout()
plt.savefig(os.path.join(outputs_dir, f"nn_distance_hist_all.png"), dpi=150)
plt.close()
if __name__ == "__main__":
main()




