🔖

99%のゴリ押しと1%の工夫でBigQueryのダミーデータを作る

2024/08/18に公開

はじめに

アプリケーション開発やテストにおいて、テストデータを作成することは避けて通れない道です。しかし、実データに近いダミーデータを大量に用意するのは時間も手間もかかる面倒な作業です。個人情報保護の観点から実データをそのまま使うわけにもいきません。

今回、実データがある程度貯まっていることを前提として、実データから全てのフィールドを匿名化しつつ、統計的な特徴を維持してダミーデータを作る方法を考えてみました。

以下では、PythonとFakerライブラリを使用して、決定論的な匿名化を行うことでファクトとディメンションの(外部キー的な)結合を維持したままダミーデータを作成していきます。また数値のフィールドに関しては、統計的な特徴を保持しつつランダムな値を生成する[1]ことで、比較的実データに近いダミーデータを作成することを目指します。

注意事項

この記事は、リアルデータに基づいたダミーデータ作成方法の一例を紹介するものですが、データの性質によっては法的・倫理的な問題が付随する可能性があります。実際のデータ利用にあたっては、個人情報保護法などの関連法規やガイドラインを遵守し、必要に応じて専門家の助言を求めるなど、十分な注意を払う必要があります。

Fakerについて

まず、この記事で使用しているFakerライブラリについて簡単に紹介します。Faker は、人名、住所、電話番号など様々な種類の偽物のデータを生成するライブラリです。日本語(ja_JP)を含む多数のロケールに対応しており、新たなロケールを追加してプルリクエストを作成することも歓迎されています。ロケールが用意されていないものについては、デフォルトのロケール(en_US)のデータが出力されます。

Fakerでは、人名、住所、電話番号といった特定の種類の偽データを生成するクラスを「プロバイダ」と呼びます。Fakerには多数のプロバイダが用意されており、こちらのドキュメントを参照するか、ソースコードを直接参照するとどういったものかよく分かるかと思われます。

2024/08/13現在、日本の車両ナンバープレートなど、一部ローカライズされていないものもあります。一方でJANコードを生成できるbarcodeプロバイダなど、多少マニアックなものもあります。

また、addressプロバイダやloremプロバイダなどは、ランダムで意味のない、それでいて住所や文章みたいな文字の羅列を出力します。これらのプロバイダを活用することで、現実に近いテストデータを効率的に作成することができます。

Fakerの出力する値は完全なランダムではなく、シード値の設定により生成データを再現できます。これにより、決定論的にデータを生成する(同じインプットから同じアウトプットを得る)ことができます。人工的なキーを使用していない場合には、結合を維持したままダミーデータを作成するのにこの性質が役に立つ場合があるかもしれません。

以下に各プロバイダの一部の使用例と取得されるデータの例を示します。

from faker import Faker

fake = Faker('ja_JP')

# addressプロバイダ
print(fake.address())  # => "石川県墨田区芝大門41丁目17番2号 シティ東三島487"
print(fake.postcode())  # => "652-3968"

# automotiveプロバイダ
print(fake.license_plate())  # => "JTH-542"

# bankプロバイダ
print(fake.bban())  # => "WXTU45197612382541"
print(fake.iban())  # => "GB94LTDJ82948832418230"

# barcodeプロバイダ
print(fake.jan())  # => "4955770833098"

# colorプロバイダ
print(fake.color())  # => "#b2ffe1"

# companyプロバイダ
print(fake.company())  # => "遠藤鉱業合同会社"

# credit_cardプロバイダ
print(fake.credit_card_number())  # => "3597150789691280"

# currencyプロバイダ
print(fake.currency())  # => "('MNT', 'Mongolian tugrik')"

# date_timeプロバイダ
print(fake.date_time())  # => "1981-09-10 04:01:25.167162"
print(fake.date_time_between())  # => "2020-11-08 16:39:37.694502"

# emojiプロバイダ
print(fake.emoji())  # => "🥺"

# fileプロバイダ
print(fake.file_path())  # => "/仕上げ/賞賛する.mp4"
print(fake.file_extension())  # => "css"

# geoプロバイダ
print(fake.latlng())  # => "(Decimal('-82.406581'), Decimal('44.047669'))"

# internetプロバイダ
print(fake.email())  # => "kobayashiyui@example.org"
print(fake.url())  # => "http://ito.net/"

# isbnプロバイダ
print(fake.isbn10())  # => "0-666-95061-X"

# jobプロバイダ
print(fake.job())  # => "運転士"

# loremプロバイダ
print(fake.paragraph())  # => "助けてカラムあなた自身敵対的な通行料金コミュニケーション。ダニブランチ普通の持っていました。賞賛する敵対的な雪職人私。"

# miscプロバイダ
print(fake.json())  # => "[{"name": "\u4f50\u3005\u6728 \u4eac\u52a9", "residency": "\u5bae\u57ce\u770c\u897f\u591a\u6469\u90e1\u5965\u591a\u6469\u753a\u5916\u56fd\u5e9c\u959325\u4e01\u76ee25\u756a19\u53f7"}, {"name": "\u77f3\u4e95 \u76f4\u6a39", "residency": "\u5c71\u5f62\u770c\u5ddd\u5d0e\u5e02\u591a\u6469\u533a\u5317\u9752\u5c7125\u4e01\u76ee9\u756a1\u53f7"}, {"name": "\u9752\u6728 \u7fd4\u592a", "residency": "\u5927\u5206\u770c\u5c0f\u5e73\u5e02\u5927\u4e2d8\u4e01\u76ee24\u756a4\u53f7 \u30af\u30ec\u30b9\u30c8\u4e38\u306e\u5185155"}, {"name": "\u6e21\u8fba \u4e03\u590f", "residency": "\u718a\u672c\u770c\u72db\u6c5f\u5e02\u7fbd\u6298\u753a33\u4e01\u76ee9\u756a18\u53f7 \u30b3\u30fc\u30dd\u6238\u585a\u753a408"}, {"name": "\u77f3\u5ddd \u5e79", "residency": "\u9ad8\u77e5\u770c\u5ddd\u5d0e\u5e02\u5ddd\u5d0e\u533a\u9ad8\u7530\u99ac\u583419\u4e01\u76ee23\u756a13\u53f7 \u30cf\u30a4\u30c4\u4e2d\u5bae\u7960978"}, {"name": "\u9234\u6728 \u5343\u4ee3", "residency": "\u9e7f\u5150\u5cf6\u770c\u9577\u751f\u90e1\u4e00\u5bae\u753a\u4eac\u6a4b19\u4e01\u76ee24\u756a20\u53f7 \u30b7\u30c6\u30a3\u62bc\u4e0a843"}, {"name": "\u677e\u672c \u304f\u307f\u5b50", "residency": "\u548c\u6b4c\u5c71\u770c\u6771\u4e45\u7559\u7c73\u5e02\u72ec\u9237\u6ca28\u4e01\u76ee27\u756a20\u53f7 \u6e2f\u5357\u30a2\u30fc\u30d0\u30f3054"}, {"name": "\u592a\u7530 \u967d\u5b50", "residency": "\u9ce5\u53d6\u770c\u8c4a\u5cf6\u533a\u6a29\u73fe\u58022\u4e01\u76ee17\u756a11\u53f7 \u4e5d\u6bb5\u5357\u30b3\u30fc\u30c8340"}, {"name": "\u5c71\u4e0b \u88d5\u7f8e\u5b50", "residency": "\u6771\u4eac\u90fd\u5c71\u6b66\u90e1\u829d\u5c71\u753a\u571f\u5442\u90e812\u4e01\u76ee9\u756a17\u53f7"}, {"name": "\u7530\u4e2d \u548c\u4e5f", "residency": "\u5317\u6d77\u9053\u897f\u591a\u6469\u90e1\u65e5\u306e\u51fa\u753a\u9db4\u30f6\u4e1824\u4e01\u76ee24\u756a17\u53f7"}]"

# passportプロバイダ
print(fake.passport_number())  # => "M42703025"

# personプロバイダ
print(fake.name())  # => "橋本 涼平"

# phone_numberプロバイダ
print(fake.phone_number())  # => "92-0036-0519"

# profileプロバイダ
print(fake.profile())  # => "{'job': '音響技術者', 'company': '株式会社佐藤農林', 'ssn': '820-80-9022', 'residence': '香川県山武郡芝山町花川戸31丁目18番18号', 'current_location': (Decimal('-68.8729685'), Decimal('28.368804')), 'blood_group': 'A+', 'website'https://www.tanaka.jp/', 'https://www.kobayashi.com/', 'http://www.kato.jp/'], 'username': 'vokada', 'name': '松田 翼', 'sex': 'M', 'address': '埼玉県袖ケ浦市花島14丁目1番18号 元浅草ハイツ751', 'mail': 'takumahayashi@yahoo.comme.date(1916, 9, 17)}"

# pythonプロバイダ
print(fake.pydict())  # => "{'狭い': 'lqpYTVjWYhglhOMzZMwD', '立派な': 'AXltuNBsJuBtsqFxxZfY', 'キャビネット': datetime.datetime(1994, 2, 15, 1, 3, 17, 695999), '大統領': datetime.datetime(2023, 7, 8, 5, 48, 55, 166192), 'フレーム': 'WcDRTMdDUDdkabRTsiKn'ta@example.org'}"

# sbnプロバイダ
print(fake.sbn9())  # => "265-12510-3"

# ssnプロバイダ
print(fake.ssn())  # => "646-87-9945"

# user_agentプロバイダ
print(fake.user_agent())  # => "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_12_2 rv:5.0; id-ID) AppleWebKit/531.19.1 (KHTML, like Gecko) Version/5.0.5 Safari/531.19.1"

上記のサンプルを見ていただけると分かるように、かなりリアルなダミー値が得られるため、たまたま現実に存在する値と被ってしまうこともあり得ます。そのため、現実に絶対に存在しない値を使いたい場合には、faker以外の方法を検討するべきかもしれません。

実際にダミーデータを作る

以下では、Pythonスクリプトによって、BigQueryの実データからダミーデータを作成する例を示します。

スクリプトはローカル環境からでも実行可能なものとして作成します。

0. 前提条件

  1. Google Cloudプロジェクト
    • Google Cloudプロジェクトが作成されている
    • BigQueryのソースデータセット、ダミーデータを格納する宛先データセットが存在する
    • ソースデータセットにダミーデータを作成する対象の実データがある
  2. 権限
    • スクリプトの実行ユーザまたはサービスアカウントは、ソースデータセットに対する読み取り、宛先データセットに対する書き込み権限を持つ
  3. API有効化
    • BigQuery APIが有効化されている
    • BigQuery Storage APIを使用する場合[2]には、これも有効化する
  4. 認証
    • ローカルで実行する場合、Google Cloud SDKがインストールされ、認証情報が適切に設定されている
    • Cloud Functionsなどの環境で実行する場合、サービスアカウントに適切な権限が付与されている

1. 依存パッケージのinstall

BigQuery Storage APIを使用する場合、google-cloud-bigquery-storageもインストールします。

pip install google-cloud-bigquery google-cloud-bigquery-storage pandas numpy db-dtypes faker

2. スクリプトを書く

ソースデータセットからデータを読み込み、全てのフィールドをダミーに置き換えた後、宛先データセットに保存するスクリプトを記載します。ディメンションとファクトの結合を保つことを意識し、キー的な役割を担う"id"が含まれるカラム[3]は決定論的にハッシュ値が得られるようにしています。

以下にサンプルコードを示します。ポイントに数字付きでコメントを記載しています。

import base64
import hashlib

import numpy as np
import pandas as pd
from faker import Faker
from google.cloud import bigquery

# 1. 処理対象のプロジェクトID、ソースデータセットID、宛先データセットIDです。場合により環境変数から取得するようにすると良いと思います
PROJECT_ID = "project-id"
SOURCE_DATASET_ID = "source_dataset"
DESTINATION_DATASET_ID = "destination_dataset"

# 2. 日本語ロケールのインスタンスを取得するために、"ja_JP"を指定します
fake = Faker("ja_JP")
client = bigquery.Client(project=PROJECT_ID)

# 3. 元の値に基づいて、指定されたタイプの偽データを決定論的に生成します(元のデータが同じ場合、常に同じ偽データが生成される)
def generate_deterministic_fake_data(original_value, fake_data_type):
    seed = int(hashlib.sha256(original_value.encode()).hexdigest(), 16) % (2 ** 32 - 1)
    fake.seed_instance(seed)
    return getattr(fake, fake_data_type)()

# 4. SHA-256ハッシュ化 + Base64エンコード文字列
def hash_id_column(value):
    return base64.b64encode(hashlib.sha256(value.encode()).digest()).decode('utf-8')

# 5. 数値型の列の統計情報から、正規分布に基づくランダムな値(の絶対値)で置き換える
def anonymize_numeric_column(series):
    mean = series.mean()
    std = series.std()
    if series.dtype == np.int64:
        return np.abs(np.random.normal(mean, std, size=len(series))).astype(np.int64)
    else:
        return np.abs(np.random.normal(mean, std, size=len(series))).astype(np.float64)

# 6. 列名や型に応じて匿名化処理を実行する
def anonymize_column(df, col):
    if "id" in col.lower():
        return df[col].astype(str).apply(hash_id_column)
    elif pd.api.types.is_numeric_dtype(df[col]):
        return anonymize_numeric_column(df[col])
    # 7. 住所文字列など、Fakerにプロバイダが存在するものに関しては、Fakerで簡単に置き換え可能
    elif "address" in col.lower():
        return df[col].astype(str).apply(lambda x: generate_deterministic_fake_data(x, "address"))
    elif "name" in col.lower() or "user" in col.lower():
        return df[col].astype(str).apply(lambda x: generate_deterministic_fake_data(x, 'name'))
    else:
        # 8. データ変換が行われないカラムがあった場合例外となるようにすることで、データ変換の考慮漏れを防ぐことにします
        raise Exception(f"無変換のカラムがあります: {col}")


def main():
    source_dataset_ref = f"{PROJECT_ID}.{SOURCE_DATASET_ID}"
    destination_dataset_ref = f"{PROJECT_ID}.{DESTINATION_DATASET_ID}"

    tables = list(client.list_tables(source_dataset_ref))

    for table_item in tables:
        source_table_ref = f"{source_dataset_ref}.{table_item.table_id}"
        destination_table_ref = f"{destination_dataset_ref}.{table_item.table_id}"

        df = client.list_rows(source_table_ref).to_dataframe()

        for col in df.columns:
            df[col] = anonymize_column(df, col)

        # 9. WRITE_TRUNCATEとしているため、宛先データセットに同名テーブルが存在する場合には上書きされることに注意
        job_config = bigquery.LoadJobConfig(write_disposition="WRITE_TRUNCATE")
        job = client.load_table_from_dataframe(df, destination_table_ref, job_config=job_config)
        job.result()
        print(f"テーブルへの出力が完了しました: {destination_table_ref}")


if __name__ == '__main__':
    main()

3. 改善事項

各テーブル数千レコード×数十カラム程度の規模なら、上記のコードは十分早く動作すると思われます。しかし、データ量が増大するにつれて処理速度が問題となる可能性があります。この場合、以下などの改善策が考えられます。

  • IDのハッシュ化や統計処理など、可能な処理をBigQueryで計算する
  • Dataflowのカスタムテンプレートを作成し、データ処理を並列化する

まとめ

この記事では、BigQueryに存在する実データから、PythonとFakerライブラリを使用してダミーデータを作る方法を紹介しました。特に、決定論的な匿名化を行い、テーブル間の結合関係を維持したままとなることを目指しました。数値においては、統計的な特徴を残す方法の一例として、正規分布に基づくランダムな値で置き換えることにしました。

プロバイダが対応しているフィールドに関しては、Fakerを使うことによってリアルな偽データを簡単に作ることも可能です。

最後に改めての注意ですが、ダミーデータの作成や利活用において、関連法規やガイドラインを遵守して必要に応じて専門家の助言を求めるなど、データの取り扱いには十分な注意を払うようにしてください。

脚注
  1. 今回は正規分布に基づくランダムな値を使用するという初歩的なもの ↩︎

  2. 大量データ処理の高速化に寄与する可能性があるが、コストが増大する可能性もある ↩︎

  3. 結構粗く場合分けしており、例えば"idol"、"idea"のようなカラム名があった場合には分岐から、ハッシュ値が計算されてしまいます ↩︎

OPTIMINDテックブログ

Discussion