Digital Reactor
合成データ・プライバシー

ADS-GANによる合成データ生成、分布・ユーティリティ・プライバシーの評価

ADS-GANによる合成データ生成、分布・ユーティリティ・プライバシーの評価

はじめに

使いたいデータが手元にあっても、個人情報である以上、そのまま共有や外部分析に回すことはできません。実務でデータを扱ううえで繰り返し直面する制約です。有力な解決策の一つが合成データです。元データの統計的性質をある程度保ちながら、個人そのものは含まないデータを生成します。うまく機能すれば、プライバシーを確保したまま分析やモデル開発に活用できます。

今回はUCI Adult Census Incomeを題材に、synthcityのADS-GAN(Adversarial De-identification Synthetic GAN)で合成データを生成します。sexraceをセンシティブ属性に指定し、生成したデータを三つの観点から評価します。元データの分布をどの程度保持できているか(分布比較)、実データ分析の代替としてどこまで有用か(ユーティリティ評価)、個人をどの程度秘匿できているか(プライバシー評価)です。

調整の中心になるのがADS-GANのlambda_identifiability_penaltyです。この値を動かすと、プライバシーとユーティリティのどちらを優先するかが変わります。後半ではλを0.3、1.0、3.0と変え、その影響が評価指標にどう現れるかを検証します。結論を先取りすると、λ=3.0では合成データがほぼ機能しなくなるほど品質が崩れました。

対象読者:

  • 合成データ生成技術に関心のあるデータサイエンティストや研究者
  • プライバシー保護とデータ活用のバランスを模索している開発者
  • synthcity ライブラリのADS-GANを実践的に使ってみたい方
  • 合成データの評価指標について理解を深めたい方

記事のポイント:

  • synthcityのADS-GANで合成データを生成する具体的な手順を示します。
  • 合成データの「分布比較」「ユーティリティ評価」「簡易プライバシー評価」の一連の流れをコードと結果で追えます。
  • lambda_identifiability_penaltyがプライバシーとユーティリティのトレードオフに与える影響を、3水準の実験で比較します。
  • 実データ分析の代替として合成データを使うときの限界と、調整の指針が分かります。

セットアップ

コードの実行には次のライブラリを使います。

pip install "synthcity[all]" scikit-learn pandas numpy

データセットとセンシティブ列

題材はUCI Adult Census Incomeデータセットです。年齢・教育・職業といった属性から年収が<=50K>50Kかを当てる、収入予測の定番ベンチマークです。sklearn.datasets.fetch_openml("adult", version=2, as_frame=True)で取得できます。

目的変数はincome、そしてセンシティブ属性としてsexraceを指定します。この二つを、合成データ生成のプライバシー保護の対象に据えます。

コードの抜粋

まず合成データの生成までを示します。以下は主要なステップを抜き出したコードで、実行可能な完全版は記事末尾の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を結合し、GenericDataLoadersynthcityが扱える形式に変換します。目的変数とセンシティブ属性を指定したうえで、ADS-GANを実データで学習させ、5000件を生成します。

合成データの評価

生成した合成データが実用に足るかを調べます。元の分布をどの程度保持できているか、モデル学習の材料としてどこまで通用するか、そして個人がどの程度秘匿されているかです。

分布比較(sex / race)

まず、センシティブ属性の周辺分布が元データとどの程度近いかを確認します。今回はsexraceについて、各カテゴリの割合を実データと合成データで並べます。

割合を突き合わせれば、特定のカテゴリが過剰に生成されたり欠落したりしていないかが分かります。ここが元データから大きくずれていれば、そもそもデータの基本的な構造を捉えられていないという判断になります。

ユーティリティ評価(実→実 vs 合成→実)

ユーティリティ(有用性)は、合成データが実データの代わりにモデル学習の材料として使えるかを測る指標です。ロジスティック回帰を用い、二つのシナリオで予測性能を比較します。

一つは実データで学習し実データで評価したものです。これが性能の上限ベースラインとなり、合成データがどこまで迫れるかの基準になります。もう一つは合成データで学習し実データで評価したもので、こちらが実用度を直接示します。指標はAccuracyとROC-AUCの二つです。両シナリオの差が小さいほど、合成データは実データに近い有用性を持つと判断できます。

簡易プライバシー評価(最近傍距離)

最後に、合成データが元の個人をどの程度秘匿できているかを、最近傍距離で簡易的に測ります。

手順は次のとおりです。実データと合成データを同じ前処理空間(スケーリングなど)へ写像し、合成データの各サンプルについて、実データの中で最も近いサンプルまでの距離を求めます。

距離が極端に小さいサンプルが多数あれば、実データのほぼ複製が紛れ込んでいる疑いがあります。元の個人を再識別されるリスクが高い状態です。ただし、これは差分プライバシーのような数学的保証ではありません。あくまでリスクの傾向を把握するための目安として扱います。

実行方法

ここまでの一連の処理を通しで実行できるスクリプト script.py を、記事末尾に載せています。次のコマンドで、生成から評価までをまとめて実行できます。

python script.py

スクリプトを実行すると、合成データの先頭5行、sexraceの分布比較、ユーティリティ評価(Accuracy / ROC-AUC)、そして最近傍距離の要約統計量と極小距離の割合が順に出力されます。この四つを照らし合わせると、品質とプライバシーの両面から判断できます。

lambda_identifiability_penaltyとは

ADS-GANの挙動を最も大きく左右するのがlambda_identifiability_penaltyです。損失関数に組み込まれた識別可能性ペナルティ項の重みで、「元データに似せる」「個人を特定させない」という二つの要求のうち、後者をどれだけ強く効かせるかを決めます。

つまりこの値は、ユーティリティとプライバシーのどちらに比重を置くかを直接制御します。

値を大きくすると、モデルはプライバシー側に寄ります。再識別リスクは抑えられ、元の個人は特定しにくくなります。その代わり、生成データの統計的性質は元データから乖離しやすく、合成データで学習したモデルの性能は低下しがちです。分布そのものも、元を忠実に再現するより識別可能性を下げる方向へ変形していきます。

逆に値を小さくすると、ユーティリティ側に寄ります。実データに近い分布や特徴を保ちやすく、実データで学習したモデルに近い性能が得られることもあります。ただし識別可能性の抑制が弱いぶん、個人を特定されるリスクは高まります。

実務での適正値は、データの性質と利用目的によって変わります。プライバシーを最優先するなら1.0〜3.0程度の高め、両者のバランスを取るなら0.3〜1.0、ユーティリティを最大化するなら0.1以下、というのが大まかな出発点です。ただし一意に決められる値ではなく、ユーティリティ・分布・プライバシーの各指標を確認しながら、目的に合う点を見極めることになります。

λを0.3、1.0、3.0で振って比べる

lambda_identifiability_penaltyが品質にどう作用するかを見るため、n_iter=30に固定し、λを0.3、1.0、3.0の3段階で比較しました。ユーティリティ・分布・最近傍距離の三つの指標を、同じ条件で比べます。

ユーティリティ評価結果

実データで学習したモデル(real→real)をベースラインに、各λで生成した合成データで学習したモデル(synth→real)の予測性能を並べます。

lambda_identifiability_penaltyAccuracy (real→real)ROC-AUC (real→real)Accuracy (synth→real)ROC-AUC (synth→real)
0.30.85350.90610.82240.8685
1.00.85350.90610.81180.8740
3.00.85350.90610.72850.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ポイント落ちて0.3758になります。ROC-AUCが0.5を下回るのは、ランダムな予測より劣ることを意味します。この設定では、合成データに予測へ寄与する情報がほとんど残っていません。プライバシー保護を過度に優先した結果、生成データの品質が決定的に損なわれた例です。

ユーティリティ比較

分布比較結果

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

sex分布比較

race分布比較

λ=0.3と1.0では、sexraceも実データの周辺分布をおおむね再現できています。基本的な構造は捉えられているという結果です。一方、λ=3.0では分布が実データからはっきり乖離します。元の形の再現よりも識別可能性の低減を優先した結果、データが変形したためです。ユーティリティの崩壊は、この分布のずれと連動して生じていました。

最近傍距離によるプライバシー評価

最後に、合成サンプルから実サンプルへの最近傍距離の分布を見て、プライバシーリスクの傾向を確かめます。

最近傍距離ヒストグラム

λ=0.3と1.0では距離が比較的狭い範囲に集中しており、実データとほぼ一致するような極端に近いサンプルは見当たりません。λ=3.0では分布が広がり、実データから遠いサンプルが増えます。再識別リスクの観点では望ましい方向ですが、そのぶん分布は歪んでいます。プライバシーの強化とユーティリティの低下は、識別可能性を下げるための同じ変形から生じています。

今回の設定で機能したλの範囲

一連の結果から、lambda_identifiability_penaltyはプライバシーとユーティリティのトレードオフをそのまま制御するパラメータだと確認できます。λを上げるほどプライバシー側に寄り、ROC-AUCを中心にユーティリティが低下します。

実用的に機能したのはλ=0.3〜1.0の範囲です。このレンジであれば、ユーティリティを保ちつつ一定のプライバシー保護も両立できます。λ=3.0は、少なくとも今回の設定では過剰でした。

λの選択は利用目的によって決まります。探索的分析やモデルの試作のようにユーティリティを重視する場面では低めに、機密データの共有や公開のようにプライバシーを最優先する場面では高めに設定します。どちらの場合も、今回のようにユーティリティ・分布・最近傍距離の指標を突き合わせたうえで決めます。

調整のヒント

今回は最小構成での検証だったため、品質を高める余地は多く残っています。

まずn_iterです。実験の都合で30に固定しましたが、品質を追求するなら1000以上を見込むべきです。学習を重ねるほど、モデルは元データの複雑なパターンを捉えられるようになります。lambda_identifiability_penaltyは今回の結果を踏まえ、用途に応じて0.3〜3.0で調整してください。Accuracy・ROC-AUC・分布・最近傍距離を見ながら、許容できる水準を見極める作業になります。今回の範囲では0.3〜1.0が妥当でした。

評価側にも改善の余地があります。ユーティリティ評価の分類器をXGBoostやLightGBMに替えれば、合成データの性能限界をより正確に測定できます。生成前の前処理(カテゴリのエンコーディング、数値のスケーリング)も品質を大きく左右するため、慎重に設計する価値があります。

プライバシーの評価も、最近傍距離だけでは不十分です。メンバーシップ推論攻撃や属性推論攻撃をシミュレートすれば、より厳密に検証できます。加えて、今回はsexraceをプライバシーの対象としてのみ扱いましたが、これらは公平性の軸でもあります。属性グループ間で予測性能に差が生じていないか、合成が元データの公平性をどう変化させるかも、実務では併せて確認します。

まとめ

UCI Adultを題材に、ADS-GANによる合成データの生成から、分布・ユーティリティ・最近傍距離での評価までを一通り動かしました。lambda_identifiability_penaltyはプライバシーとユーティリティの比重を直接動かすパラメータで、今回の設定で実用に耐えたのは0.3〜1.0の範囲でした。3.0まで上げるとROC-AUCがランダム以下に落ち、プライバシーは強まっても分析の材料としては使えないデータになります。強く保護するほど良いという単調な関係ではないため、λは指標を測りながら決めるしかありません。

自分のデータで試すなら、評価用の分類器と前処理を先に固定し、λ=0.3〜1.0、n_iterは1000以上を出発点にして、生成のたびに今回と同じ三つの指標を記録していくと、そのデータでλをどこまで上げられるかを根拠を持って判断できます。

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()

関連記事

← 技術ブログ一覧へ