Skip to contents

この文書は、自分のデータを加工し、それを攻撃し、出てきた数値を読むという 一連の作業を、1 つのデータセットに対して最後まで通します。

既存の文書との住み分けは次のとおりです。

文書 答えること
概要ページ なぜ測るのか
vignette「再識別リスクの測り方」 なぜそう測るのか(測り方を間違えると危険なデータが安全に見える)
README どう使うか(関数ごとの説明)
この文書 加工 → 攻撃 → 評価を、自分のデータでどう回すか

加工の関数はこのパッケージにありません

先に断っておきます。reidentify に「匿名加工を実行する関数」は基本的にありません。 これは攻撃と評価のためのツールだからです。 generalize_value() / generalization_hierarchy() / coarsen_time() / coarsen_place() は加工の部品ですが、 「どの列をどう潰すか」を決めて適用するのは利用者の仕事です。

したがって以下では、加工を base R で手書きします。 実務の加工はどのみち自社のスキーマに合わせた手書きになるので、 そのまま持ち帰れる形にしてあります。

以下のコードはすべて実行され、表示されている数値はこの文書をビルドしたときに 生成されたものです。乱数はすべて set.seed() で固定してあります。


1. 出発点 — 加工前のデータで、どれだけ特定できるか

1.1 元データ

300 人分のダミーデータを元データ(RAW)とします。

N   <- 300L
raw <- as.data.frame(create_dummy_qi_data(people = N, seed = 20260802))
str(raw, vec.len = 3)
#> 'data.frame':    300 obs. of  9 variables:
#>  $ ROW_NUMBER : int  1 2 3 4 5 6 7 8 ...
#>  $ ID         : int  10001 10002 10003 10004 10005 10006 10007 10008 ...
#>  $ AGE        : int  75 47 45 54 49 51 62 23 ...
#>  $ ZIP        : chr  "Z044" "Z015" "Z002" ...
#>  $ SEX        : chr  "F" "M" "F" ...
#>  $ VISIT_COUNT: int  15 8 13 14 11 4 4 19 ...
#>  $ SPEND_MEAN : num  54.9 54.4 50.5 49.1 44.6 48.2 48.8 65.7 ...
#>  $ SPEND_DIST : chr  "82:61:92:96:22:34:88:73:18:62:26:57:43:48:21" "74:15:92:91:3:33:28:99" "65:17:35:30:3:99:46:33:91:13:34:96:95" ...
#>  $ FINGERPRINT: num  0.91643 0.7392 0.00608 0.34088 ...

列の役割を先に決めます。この分類が加工方針そのものです。

分類 保有者の想定
ID, FINGERPRINT 直接識別子 公開してはならない
AGE, ZIP, SEX 準識別子 単独では個人を指さないが、組み合わせると指す
VISIT_COUNT, SPEND_MEAN 行動特徴 分析に必要なので残したい
SPEND_DIST 明細 今回は公開しない

1.2 一般化の階層を宣言する

年齢と郵便番号は後で粗くします。粗くした値を後から正しく突き合わせるには、 どの値がどの区画に入るのかを宣言しておく必要があります。 年齢は同梱の階層(5 歳刻み → 10 歳刻み)を、 郵便番号は 10 個ずつまとめる階層をその場で作ります。

base_hier <- read_generalization_hierarchy(
  system.file("extdata", "generalization-jp.csv", package = "reidentify")
)
zips <- sort(unique(raw$ZIP))

hier <- generalization_hierarchy(rbind(
  base_hier$edges[base_hier$edges$attribute == "AGE",
                  c("attribute", "value", "parent")],
  data.frame(attribute = "ZIP",
             value     = zips,
             parent    = sprintf("ZG%02d", ceiling(seq_along(zips) / 10)),
             stringsAsFactors = FALSE)
))
hier
#> generalization hierarchy: 80 edge(s) over 2 attribute(s)
#>   AGE: 20 edge(s), 2 level(s), root(s): [0,10), [10,20), [20,30), [30,40), [40,50)
#>   ZIP: 60 edge(s), 2 level(s), root(s): ZG01, ZG02, ZG03, ZG04, ZG05

1.3 攻撃を 1 つ決めて、以後は変えない

加工の効果を比べるには、攻撃側を固定する必要があります。 ここで決める攻撃者は次のようにふるまいます。

  • 元データのレコードを持っている(最強の想定。§3 で緩めます)
  • 公開データに載っている列を、全部使う
  • 準識別子は score_containment() で突き合わせる。 一般化されていても、抑止されていても、生の値のままでも、 「この公開値に入りうる元レコードは誰か」という同じ問いで扱えます
  • 行動特徴は score_num() で突き合わせる(ノイズが乗っても距離は意味を持つ)
QI  <- c("AGE", "ZIP", "SEX")
NUM <- c("ID", "VISIT_COUNT", "SPEND_MEAN", "FINGERPRINT")

## 公開データ 1 つにつき、スコア層を 1 度だけ計算する
score_layer <- function(anon) {
  pairs <- join_raw_anon_data(raw, anon)
  list(
    qi  = score_containment(pairs, intersect(QI, names(anon)), hierarchy = hier),
    num = lapply(intersect(NUM, names(anon)),
                 function(v) score_num(pairs, v))
  )
}

## 統合層 + 割当層 + 評価。尺度は zscore でそろえる
attack <- function(layer, use_num = TRUE, seeds = 1:10) {
  parts <- c(list(layer$qi), if (use_num) layer$num)
  sc <- if (length(parts) == 1L) parts[[1L]]
        else combine_scores(normalize_scores(parts, "zscore"))
  reid_evaluate(sc, seeds = seeds)
}

## 段階ごとに 2 通り測る: 公開列を全部使った攻撃と、準識別子だけの攻撃。
## 結果は使い回すので、ここで 1 度だけ計算して持ち回る。
measure <- function(layer) list(all = attack(layer),
                                qi  = attack(layer, use_num = FALSE))
rates <- function(m) c(全列 = m$all$success_analytic,
                       準識別子のみ = m$qi$success_analytic)

1.4 ベースライン — 何も加工せずに公開したら

まず「明細だけ落として、あとはそのまま公開する」段階を測ります。 これが以降すべての比較の基準です。

S0 <- raw[, c("ROW_NUMBER", "ID", "AGE", "ZIP", "SEX",
              "VISIT_COUNT", "SPEND_MEAN", "FINGERPRINT")]
L0 <- score_layer(S0)
M0 <- measure(L0)
M0$all
#> reid evaluation: 300 ANON x 300 RAW record(s), 90000 candidate pair(s)
#>   success rate   : 1.0000 exact | simulated mean 1.0000 sd 0.0000 range [1.0000, 1.0000] over 10 seeds
#>   baseline       : random 0.0033 | mode 0.0033   (lift vs random: 300.00x)
#>   top-k hit rate : k=1 1.0000  k=5 1.0000  k=10 1.0000
#>   max per-record risk: 1.0000
#>   precision-recall (threshold on attacker-visible CONFIDENCE, margin):
#>     conf >= 10.1415 : attack 1/300 (0.3%)  precision 1.0000  recall 0.0033
#>     conf >= 9.8311 : attack 2/300 (0.7%)  precision 1.0000  recall 0.0067
#>     conf >= 9.8257 : attack 3/300 (1.0%)  precision 1.0000  recall 0.0100
#>     conf >= 9.8110 : attack 4/300 (1.3%)  precision 1.0000  recall 0.0133
#>     conf >= 9.8057 : attack 5/300 (1.7%)  precision 1.0000  recall 0.0167
#>     ... 295 more threshold(s)

ID が載っているので当然 100% です。この行は「測定が動いていること」の 確認として置いています。 加工の効果は、ここからどれだけ下がるかで測ります。

準識別子だけを見た場合も、ほぼ同じ水準です。

rates(M0)
#>         全列 準識別子のみ 
#>         1.00         0.99
unicity_fraction(raw, QI)
#> [1] 0.98

AGE ZIP SEX の 3 列だけで、300 人中ほぼ全員が一意に決まっています。


2. 加工する

ここから 5 種類の加工を積み上げて適用します。 各段階は前の段階の出力を加工したもので、後戻りはしません。

2.1 直接識別子を落とす

最初にやることは決まっています。IDFINGERPRINT を消します。

S1 <- S0
S1$ID          <- NULL
S1$FINGERPRINT <- NULL
names(S1)
#> [1] "ROW_NUMBER"  "AGE"         "ZIP"         "SEX"         "VISIT_COUNT"
#> [6] "SPEND_MEAN"

L1 <- score_layer(S1)
M1 <- measure(L1)
rates(M1)
#>         全列 準識別子のみ 
#>         1.00         0.99

変わりません。 全列でも準識別子だけでも、加工前と同じ値のままです。

直接識別子を消すことは必要ですが、それだけでは何も減りません。 残った 5 列が、消した 2 列と同じだけ個人を指しているからです。 「識別子を削除した」という記述は、加工の完了ではなく着手を意味します。

2.2 一般化する

年齢を 10 歳刻みに、郵便番号を 10 個ずつのグループに丸めます。 1.2 で宣言した階層を generalize_value() に渡すだけです。

S2 <- S1
S2$AGE <- generalize_value(S2$AGE, "AGE", hier, levels = 1)
S2$ZIP <- generalize_value(S2$ZIP, "ZIP", hier, levels = 1)
head(S2, 3)
#>   ROW_NUMBER     AGE  ZIP SEX VISIT_COUNT SPEND_MEAN
#> 1          1 [70,80) ZG05   F          15       54.9
#> 2          2 [40,50) ZG02   M           8       54.4
#> 3          3 [40,50) ZG01   F          13       50.5

L2 <- score_layer(S2)
M2 <- measure(L2)
rates(M2)
#>         全列 準識別子のみ 
#>    0.9966667    0.2333333

準識別子だけを見れば、一般化は効いています (0.99 から 0.23 に下がりました)。 ところが全列を使った成功率はほとんど動きません。

理由は 1 列で説明できます。

c("SPEND_MEAN の異なり数" = length(unique(S2$SPEND_MEAN)),
  "SPEND_MEAN 単独のユニシティ" = unicity_fraction(S2, "SPEND_MEAN"),
  "準識別子 3 列のユニシティ"   = unicity_fraction(S2, QI))
#>       SPEND_MEAN の異なり数 SPEND_MEAN 単独のユニシティ 
#>                 202.0000000                   0.4366667 
#>   準識別子 3 列のユニシティ 
#>                   0.0100000

SPEND_MEAN は小数第 1 位まで載った実数で、準識別子より細かい粒度を持っています。 準識別子をどれだけ粗くしても、この列が素通しである限りリスクは下がりません。

加工方針を「準識別子」という語で立てると、この列は視界から外れます。 分類上の準識別子ではなく、実際に個人を指している列を測って見つけてください。 列ごとの unicity_fraction() はそのための最短の道具です。

2.3 抑止する

k 匿名性の考え方で、(AGE, ZIP, SEX) の組が 5 人未満のセルを潰します。 まず ZIP をワイルドカード "*" に置き換え、それでも 5 人に満たないセルは AGE も潰します。

cell_size <- function(d) {
  key <- paste(d$AGE, d$ZIP, d$SEX, sep = "|")
  as.integer(table(key)[key])
}

S3 <- S2
S3$ZIP[cell_size(S3) < 5L] <- "*"
S3$AGE[cell_size(S3) < 5L] <- "*"

c("ZIP を抑止した件数" = sum(S3$ZIP == "*"),
  "AGE を抑止した件数" = sum(S3$AGE == "*"),
  "最小セルサイズ"     = min(cell_size(S3)))
#> ZIP を抑止した件数 AGE を抑止した件数     最小セルサイズ 
#>                120                 12                  4

L3 <- score_layer(S3)
M3 <- measure(L3)
rates(M3)
#>         全列 準識別子のみ 
#>    0.9966667    0.1190223

score_containment() はワイルドカードを「どの値でもよい」と読むので、 抑止したレコードは候補が広がり、準識別子だけの成功率はさらに下がります。 全列の成功率は、やはり動きません。

ただし「抑止は効かなかった」と結論しないでください。 SPEND_MEAN が素通しである限り全列の成功率は下がりようがない、 というだけです。同じ加工でも順序を最後に回せばはっきり効いて見えますし(§2.7)、 抑止されたレコードだけを見れば平均リスクは大きく下がっています(§4.2)。

2 段かけても最小セルサイズが 5 に届いていない点にも注意してください。 AGE を潰すとセルの区切り自体が変わるため、 1 回の走査では k 匿名性は成立しません。「k=5 を目指した加工」と 「k=5 が成立した加工」は別物です。 実測してください。

2.4 ノイズを加える

ここまで手つかずだった行動特徴に手を入れます。 訪問回数に ±2 回、支払平均に標準偏差 5 の正規ノイズを加えます。

S4 <- S3
set.seed(4321)
S4$VISIT_COUNT <- S4$VISIT_COUNT + sample(-2:2, N, replace = TRUE)
S4$SPEND_MEAN  <- round(S4$SPEND_MEAN + stats::rnorm(N, sd = 5), 1)

L4 <- score_layer(S4)
M4 <- measure(L4)
rates(M4)
#>         全列 準識別子のみ 
#>    0.5716667    0.1190223

ここではじめて全列の成功率が動きます。 準識別子側は 2.3 から 1 ミリも変わっていません(AGE ZIP SEX に触っていないので当然です)。

ただし「効いたのはノイズだけだった」と読まないでください。 ここで見えている差は「ノイズ単独の効果」ではなく、 「一般化と抑止を先に済ませた状態に、さらにノイズを足したときの差」です。 この差が誰に付くかは、加工を並べた順序で変わります。 §2.7 で実測します。

とはいえ半分は特定されたままです。ノイズの大きさに対して、 SPEND_MEAN の個人差の方が大きいからです。

2.5 サンプリングする

「全員を出さなければ安全」という発想で、半数だけを公開します。

set.seed(8765)
S5 <- S4[sort(sample.int(N, N / 2L)), ]

L5 <- score_layer(S5)
M5 <- measure(L5)
rates(M5)
#>         全列 準識別子のみ 
#>    0.5233333    0.1173139

ほとんど下がりません。 2.4 の 0.57 に対して 0.52 です。

成功率の母数が変わっていることに注意してください。 ここでの成功率は「公開されたレコードのうち何割が元の個人に戻ったか」です。 母集団 300 人のうち何人が特定されたかで数えれば、公開を半分にした分だけ 人数は減ります。しかし公開されてしまった 1 人から見たリスクは、 ほとんど変わっていません。 サンプリングは「誰かを守る」加工ではなく 「対象を減らす」加工です。

しかもここには落とし穴があります。公開データだけを見たユニシティは、 サンプリングで逆に上がります。

c("S4 の中でのユニシティ" = unicity_fraction(S4, QI),
  "S5 の中でのユニシティ" = unicity_fraction(S5, QI))
#> S4 の中でのユニシティ S5 の中でのユニシティ 
#>            0.00000000            0.04666667

同じ組を持つ相手が抜けた分、公開表の中では一意になる人が増えます。 攻撃者が突き合わせる相手は公開表ではなく元の母集団なので、 この数値は安全性の指標になりません。 ユニシティは元データ側で測ってください。

2.6 段階ごとの実測

ここまでを 1 枚にまとめます。

## 2 節で計算済みの測定結果を並べ直すだけ。ここで測り直してはいない。
stages <- list("0 無加工"         = M0, "1 直接識別子を削除" = M1,
               "2 一般化"         = M2, "3 抑止"             = M3,
               "4 ノイズ"         = M4, "5 サンプリング"     = M5)

## 列名は ASCII にしてある。データフレームの列名は make.names() を通るので、
## 非 ASCII 名はロケール次第で書き換わることがある。
##   stage   段階
##   n_anon  公開件数
##   all     全列を使った成功率
##   qi_only 準識別子だけを使った成功率
##   random  ランダム割当のベースライン
##   maxrisk レコード別リスクの最大値
summary_table <- do.call(rbind, lapply(names(stages), function(nm) {
  ev <- stages[[nm]]$all
  data.frame(
    stage   = nm,
    n_anon  = length(unique(ev$per_record$ANON_ROW_NUMBER)),
    all     = round(ev$success_analytic, 4),
    qi_only = round(stages[[nm]]$qi$success_analytic, 4),
    random  = round(ev$baseline$rate[ev$baseline$method == "random"], 4),
    maxrisk = round(max(ev$per_record$RISK), 4),
    stringsAsFactors = FALSE
  )
}))
print(summary_table, row.names = FALSE)
#>               stage n_anon    all qi_only random maxrisk
#>            0 無加工    300 1.0000  0.9900 0.0033       1
#>  1 直接識別子を削除    300 1.0000  0.9900 0.0033       1
#>            2 一般化    300 0.9967  0.2333 0.0033       1
#>              3 抑止    300 0.9967  0.1190 0.0033       1
#>            4 ノイズ    300 0.5717  0.1190 0.0033       1
#>      5 サンプリング    150 0.5233  0.1173 0.0033       1
## 前の段階からどれだけ下がったか(累積デルタ)。
## **この並び順で積み上げたときの**値であることに注意(§2.7)。
d <- summary_table$all
print(data.frame(stage = summary_table$stage[-1],
                 delta = round(d[-1] - d[-length(d)], 4),
                 stringsAsFactors = FALSE),
      row.names = FALSE)
#>               stage   delta
#>  1 直接識別子を削除  0.0000
#>            2 一般化 -0.0033
#>              3 抑止  0.0000
#>            4 ノイズ -0.4250
#>      5 サンプリング -0.0484

読み取れること。

  1. 直接識別子の削除は、単独では効果 0 でした。 想定どおりでしたが、 「ほとんど効かない」ではなく「まったく効かない」という結果です。
  2. 準識別子だけを測ると(qi_only 列)、一般化と抑止は大きく効いて見えます。 全列で測った all 列の動きとは別物です。 測る範囲を加工した列に合わせると、加工は必ず効いて見えます(§5.1)。
  3. サンプリングの効果は小さく、しかもこの差は母数が 300 件から 150 件に減った上で測った値です(§2.5)。
  4. 最大リスクは全段階を通して 1.0000 のままです。 平均が半分になっても、確実に特定される人は最後まで残ります。

delta 列を「どの加工が効いたか」の答えとして読んではいけません。 累積デルタは「前の段階からの差」です。複数の加工が同じリスクを 重複して削っているとき、削減分はすべて後に来た手に付きます。 つまりこの列は加工の性質ではなく、並べた順序の関数です。 次の §2.7 で、同じ加工・同じ乱数種のまま順序だけを入れ替えて確かめます。

2.7 どの加工が効いたのか — 順序を入れ替えて確かめる

一般化・抑止・ノイズの 3 つを、ノイズを先頭にした順S1 に適用し直します。 加工の中身も乱数種も §2 とまったく同じで、順番だけが違います。

R1 <- S1                                   # まずノイズ
set.seed(4321)
R1$VISIT_COUNT <- R1$VISIT_COUNT + sample(-2:2, N, replace = TRUE)
R1$SPEND_MEAN  <- round(R1$SPEND_MEAN + stats::rnorm(N, sd = 5), 1)

R2 <- R1                                   # 次に一般化
R2$AGE <- generalize_value(R2$AGE, "AGE", hier, levels = 1)
R2$ZIP <- generalize_value(R2$ZIP, "ZIP", hier, levels = 1)

R3 <- R2                                   # 最後に抑止
R3$ZIP[cell_size(R3) < 5L] <- "*"
R3$AGE[cell_size(R3) < 5L] <- "*"

## 出来上がった公開データは §2.4 の S4 と同一である
all.equal(R3[, sort(names(R3))], S4[, sort(names(S4))])
#> [1] TRUE

同じデータに行き着きます。 それでも累積デルタはこうなります。

rate_of <- function(anon) attack(score_layer(anon))$success_analytic

orderA <- vapply(list(S1, S2, S3, S4), rate_of, numeric(1))  # 一般化→抑止→ノイズ
orderB <- vapply(list(S1, R1, R2, R3), rate_of, numeric(1))  # ノイズ→一般化→抑止

##   step     加工
##   order_A  §2 の順(一般化 → 抑止 → ノイズ)で測った累積デルタ
##   order_B  ノイズを先頭にした順で測った累積デルタ
print(data.frame(
  step    = c("generalize", "suppress", "noise"),
  order_A = round(diff(orderA)[c(1, 2, 3)], 4),
  order_B = round(diff(orderB)[c(2, 3, 1)], 4),
  stringsAsFactors = FALSE), row.names = FALSE)
#>        step order_A order_B
#>  generalize -0.0033 -0.2317
#>    suppress  0.0000 -0.1967
#>       noise -0.4250  0.0000

同じ加工・同じデータ・同じ乱数種で、「効いた加工」が完全に入れ替わりました。 §2 の順ではノイズを当てる時点で SPEND_MEAN が素通しのままなので、 一般化と抑止には削る余地がほとんど残っていません(-0.0033 / 0.0000)。 ノイズを先に当てると今度は生の AGE ZIP SEX が素通しなので、 ノイズ側の差が 0.0000 になり、 削減分は後から来た一般化と抑止に付きます(-0.2317 / -0.1967)。

後から来た手が、重複していた削減分をまとめて受け取ります。 累積デルタは順序の関数であって、加工の性質ではありません。

順序に依存しない測り方 — leave-one-out

「この加工は効いたか」に答えるには、最終的な公開データから、その加工だけを なかったことにして測り直します。 他の加工はそのまま残します。 公開したデータを基準にした測定なので、適用した順序には依存しません。

## それぞれ「その加工を当てる前の値」に戻す。他の列はいっさい触らない。
undo_gen <- S4                             # 一般化を外す(抑止した "*" は残す)
undo_gen$AGE <- ifelse(S4$AGE == "*", "*", S1$AGE)
undo_gen$ZIP <- ifelse(S4$ZIP == "*", "*", S1$ZIP)

undo_sup <- S4                             # 抑止を外す(一般化した値に戻す)
undo_sup$AGE <- S2$AGE
undo_sup$ZIP <- S2$ZIP

undo_noise <- S4                           # ノイズを外す
undo_noise$VISIT_COUNT <- S1$VISIT_COUNT
undo_noise$SPEND_MEAN  <- S1$SPEND_MEAN

## 公開したのは半数なので、S5 と同じ行だけを取り出して測る
pub <- match(S5$ROW_NUMBER, S4$ROW_NUMBER)

loo <- data.frame(
  removed = c("(公開データそのまま)", "一般化", "抑止", "ノイズ", "サンプリング"),
  rate    = round(c(rate_of(S5),  rate_of(undo_gen[pub, ]), rate_of(undo_sup[pub, ]),
                    rate_of(undo_noise[pub, ]), rate_of(S4)), 4),
  stringsAsFactors = FALSE)
loo$delta <- round(loo$rate[1] - loo$rate, 4)
print(loo, row.names = FALSE)
#>               removed   rate   delta
#>  (公開データそのまま) 0.5233  0.0000
#>                一般化 0.9267 -0.4034
#>                  抑止 0.7567 -0.2334
#>                ノイズ 1.0000 -0.4767
#>          サンプリング 0.5717 -0.0484

delta は「その加工がなければ成功率はここまで戻っていた」という値です。 累積デルタで 0 付近だった一般化と抑止が、外すと 0.5233 から 0.9267 / 0.7567 に戻ります。 両方外すと元どおりです。

undo_both <- S4
undo_both$AGE <- S1$AGE
undo_both$ZIP <- S1$ZIP
both_off <- rate_of(undo_both[pub, ])
c("一般化と抑止を両方外す" = round(both_off, 4))
#> 一般化と抑止を両方外す 
#>                      1

「一般化と抑止は効かなかったので外してよい」と読むと、実測で成功率が 0.5233 から 1.0000 に戻ります。 §2.6 の表からその結論は出てきません。

delta の合計が、実際の下げ幅を大きく超えている点にも注意してください。

c("delta の合計"   = sum(loo$delta),
  "実際の下げ幅"   = round(rate_of(S5) - rate_of(S1), 4))
#> delta の合計 実際の下げ幅 
#>      -1.1619      -0.4767

複数の加工が同じリスクを重複して削っています。 重複しているからこそ、 累積デルタでは削減分が後の手に全部付き、leave-one-out では関係する手すべてに付きます。 片方だけを見ると判断を誤ります。

累積デルタは「積み上げの経過」を、leave-one-out は「その手の寄与」を測ります。 「どの加工が効いたか」「この加工は外してよいか」を報告書に書くなら、 leave-one-out の側です。

「どの加工が効くか」は加工の種類では決まりません。 このデータで個人を指していた列に届いたかどうかで決まり、 複数の加工が同じ列に届いていれば、効果は重複します。 重複分をどう配分するかは測り方の選択であって、データの性質ではありません。


3. 攻撃者の知識を変えて測り直す

ここまでは「元データを丸ごと持っている攻撃者」を仮定していました。 これは上界としては正しいのですが、それだけでは判断に使えません (想定の立て方は vignette「再識別リスクの測り方」§2 を参照してください)。

最終的な公開データ S5 に対して、攻撃者が見られる列を 3 水準に分けます。

水準 見える列 対応する現実
W(弱) ZIP グループのみ 公開統計・名簿からの推測
M(中) 準識別子 3 列 別サービスの会員データを持つ事業者
S(強) 準識別子 + 行動特徴 内部犯行・データ流出後
pairs5 <- join_raw_anon_data(raw, S5)

rate_for <- function(cols_qi, cols_num = character(0)) {
  parts <- list(score_containment(pairs5, cols_qi, hierarchy = hier))
  parts <- c(parts, lapply(cols_num, function(v) score_num(pairs5, v)))
  sc <- if (length(parts) == 1L) parts[[1L]]
        else combine_scores(normalize_scores(parts, "zscore"))
  reid_evaluate(sc, seeds = 1:10)$success_analytic
}

wms <- c(W = rate_for("ZIP"),
         M = rate_for(QI),
         S = rate_for(QI, c("VISIT_COUNT", "SPEND_MEAN")))
c(wms, ランダム = M5$all$baseline$rate[M5$all$baseline$method == "random"])
#>           W           M           S    ランダム 
#> 0.013109531 0.117313929 0.523333333 0.003333333

同じ公開データが、想定を変えるだけで 40 倍違います。 数値だけを報告書に書くと、この幅のどこを指しているのか読み手には分かりません。

W ですらランダム割当を上回っている点にも注意してください。 ZIP グループしか見えない攻撃者でも、当てずっぽうよりは当たります。

reid_knowledge_curve() に一般化した列を渡すとき

W / M / S の比較には reid_knowledge_curve() という専用の関数があります。 ただし quasi_identifiers に宣言する型の選び方で、 止まるか、黙って過小報告するかが変わります。 この公開データ S5 に対して、宣言できる型を全部試します。

## 型ごとに、止まるか完走するかを実際に確かめる。
## 完走した型については M 水準(準識別子 3 列)の成功率を並べる。
try_type <- function(ty) {
  tryCatch({
    kc <- suppressWarnings(reid_knowledge_curve(
      pairs5,
      quasi_identifiers = stats::setNames(rep(ty, 3), c("ZIP", "AGE", "SEX")),
      weak_subset = "ZIP", seeds = 1:2))
    sprintf("完走 (M = %.4f)", kc$success_analytic[kc$level == "M"])
  }, error = function(e) "停止")
}
value_types <- c("num", "char", "dist", "rank", "idf", "count", "profile", "span")
curve_check <- data.frame(type = value_types,
                          result = vapply(value_types, try_type, character(1)),
                          stringsAsFactors = FALSE)
print(curve_check, row.names = FALSE)
#>     type result
#>      num   停止
#>     char   停止
#>     dist   停止
#>     rank   停止
#>      idf   停止
#>    count   停止
#>  profile   停止
#>     span   停止

止まった型は正しい停止です。 抑止した "*" や一般化した "[30,40)" に 文字列距離や数値変換をかけると、意味のない低い数値が黙って出ます。

問題は止まらなかった型です。

completed <- curve_check$type[curve_check$result != "停止"]
reported  <- as.numeric(sub(".*= ", "", sub("\\)", "", curve_check$result[
  curve_check$type %in% completed])))

c("完走した型"                = paste(completed, collapse = ", "),
  "完走した型が報告する M"    = paste(sprintf("%.4f", reported), collapse = ", "),
  "自分で書き下した M の真値" = sprintf("%.4f", wms[["M"]]),
  "過小報告の倍率"            = paste(sprintf("%.1f", wms[["M"]] / reported),
                                      collapse = ", "))
#>                完走した型    完走した型が報告する M 自分で書き下した M の真値 
#>                        ""                        ""                  "0.1173" 
#>            過小報告の倍率 
#>                        ""

完走した型の M の値を、§3 冒頭で自分で書き下した M 水準の真値と比べてください。 このビルドでは 完走した型はありません。 しかも完走のときに出る警告は「この軸には信号がない」だけで、 これは「攻撃者は何も知らない = 安全」と読めてしまいます(Issue #100)。

一般化や抑止を含む公開データに対しては、現状 §3 冒頭のように 水準を自分で書き下し、score_containment() で突き合わせてください。 想定は attacker_knowledge() オブジェクトとして残せるので、 測定と分けて記録しておいてください。


4. 結果を読む

最終的な公開データ S5 に対する評価の全文です。

ev5 <- M5$all
ev5
#> reid evaluation: 150 ANON x 300 RAW record(s), 45000 candidate pair(s)
#>   success rate   : 0.5233 exact | simulated mean 0.5227 sd 0.0034 range [0.5200, 0.5267] over 10 seeds
#>   baseline       : random 0.0033 | mode 0.0067   (lift vs random: 157.00x)
#>   top-k hit rate : k=1 0.5233  k=5 0.9267  k=10 0.9733
#>   max per-record risk: 1.0000
#>   precision-recall (threshold on attacker-visible CONFIDENCE, margin):
#>     conf >= 2.5699 : attack 1/150 (0.7%)  precision 1.0000  recall 0.0067
#>     conf >= 2.1890 : attack 2/150 (1.3%)  precision 1.0000  recall 0.0133
#>     conf >= 2.1045 : attack 3/150 (2.0%)  precision 1.0000  recall 0.0200
#>     conf >= 2.0278 : attack 4/150 (2.7%)  precision 1.0000  recall 0.0267
#>     conf >= 1.3409 : attack 5/150 (3.3%)  precision 1.0000  recall 0.0333
#>     ... 144 more threshold(s)

4.1 ばらつき — 1 回の実行値を結論にしない

simulated meansdrange が、同点をどう崩すかで動く幅です。 success rate ... exact は同点を確率として数えた解析値で、 シミュレーションとは独立に計算されています。 両者が一致していることが、実装が壊れていないことの検査になります。

割当層だけのばらつきを見たいときは reid_stability() を使います。

attack_fn <- function(dat, target, seed) {
  match_greedy(score_containment(dat, target, hierarchy = hier), seed = seed)
}
reid_stability(attack_fn, pairs5, QI, seeds = 1:20)
#> reid stability over 20 tie-break seeds (trial = 150)
#>   success rate: mean 0.1177  sd 0.0258  range [0.0733, 0.1667]

4.2 誰が危ないか

平均は最悪ケースを隠します。危ないのが誰かを名指しできると、 加工方針を「もう 1 段強くする」ではなく「この人たちを守る」に変えられます。

まずレコード別リスクの分布を見ます。

per <- ev5$per_record
summary(per$RISK)
#>    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
#>  0.0000  0.0000  1.0000  0.5233  1.0000  1.0000
table("RISK = 1(確実に特定)" = per$RISK == 1)
#> RISK = 1(確実に特定)
#> FALSE  TRUE 
#>    72    78

次に、リスクが何と結びついているかを調べます。 このデータで個人を指していたのは SPEND_MEAN でした。 元データの中で「支払平均が近い人が何人いるか」でレコードを分けてみます。

## 公開レコードごとに、元データで支払平均が ±5 以内の人が何人いるか
neighbours <- vapply(S5$SPEND_MEAN,
                     function(x) sum(abs(raw$SPEND_MEAN - x) <= 5),
                     integer(1))
risk5 <- per$RISK[match(S5$ROW_NUMBER, per$ANON_ROW_NUMBER)]

by_dens <- split(risk5, cut(neighbours, breaks = c(0, 20, 40, 60, Inf),
                            labels = c("<=20", "21-40", "41-60", ">=61")))
print(data.frame(
  neighbours = names(by_dens),
  n          = vapply(by_dens, length, integer(1)),
  mean_risk  = round(vapply(by_dens, mean, numeric(1)), 3),
  stringsAsFactors = FALSE
), row.names = FALSE)
#>  neighbours  n mean_risk
#>        <=20 24     0.792
#>       21-40 14     0.571
#>       41-60 14     0.571
#>        >=61 98     0.444

neighbours は「元データで支払平均が ±5 以内にいる人数」、 mean_risk はその層の平均リスクです。)

似た人が少ない人ほど危険です。 同じデータ・同じ加工でも、 リスクはレコードによって大きく違います。

抑止をかけたレコードが実際に守られたかも確認できます。

by_sup <- split(risk5, ifelse(S5$ZIP == "*", "ZIP suppressed", "ZIP kept"))
print(data.frame(
  group     = names(by_sup),
  n         = vapply(by_sup, length, integer(1)),
  mean_risk = round(vapply(by_sup, mean, numeric(1)), 3),
  stringsAsFactors = FALSE
), row.names = FALSE)
#>           group  n mean_risk
#>        ZIP kept 90     0.683
#>  ZIP suppressed 60     0.283

抑止したレコードの平均リスクは、抑止しなかったレコードより明らかに低いままです。 §2.6 の累積デルタでは抑止の効果は 0.0000 でしたが、 守られたレコードの側から見れば効果ははっきり出ています。 §2.7 と同じ話で、全体の平均は、他の加工が先に削ったリスクに隠れます。 「効果 0」と読めるのは測り方であって、抑止されたこの 60 件に 起きたことではありません。

平均の差が「もともと似た人が多いレコードに偏って抑止がかかったから」では ないことも確かめておきます。上の密度の層ごとに分けて見ます。

## 行 = ZIP を抑止したか、列 = 元データでの近傍人数の層
print(round(tapply(risk5,
                   list(ifelse(S5$ZIP == "*", "suppressed", "kept"),
                        cut(neighbours, breaks = c(0, 20, 40, 60, Inf),
                            labels = c("<=20", "21-40", "41-60", ">=61"))),
                   mean), 3))
#>             <=20 21-40 41-60  >=61
#> kept       0.938 0.667 0.625 0.620
#> suppressed 0.500 0.000 0.500 0.227

どの層でも抑止した側が低いままです。 密度の違いでは説明できません。

4.3 top-k と精度–再現率

top-k hit rate は候補を k 件まで絞れた割合です。 5 人に絞れた時点で、その 5 人に共通する属性は既に漏れています。

精度–再現率の表は、攻撃者が自信のある一部だけを主張した場合を示します。 全体成功率が下がっても、上位だけを狙えば高い精度が残ることがあります。 CONFIDENCE の閾値はデータセット間で使い回せないので、 必ず実測分布の分位点から取ってください。

stats::quantile(reid_confidence(
  combine_scores(normalize_scores(c(list(L5$qi), L5$num), "zscore"))
)$CONFIDENCE, c(0.5, 0.9, 1))
#>       50%       90%      100% 
#> 0.2053947 0.9277809 2.5698826

5. この作業でやりがちな間違い

詳しくは vignette「再識別リスクの測り方」にありますが、 この文書の流れの中で特に踏みやすい 2 つだけ再掲します。

5.1 測る範囲を、加工した列に合わせてしまう

2 節で見たとおり、準識別子だけを測れば一般化と抑止は成功して見えました。 加工した列で測ると、加工は必ず効いているように見えます。 測る範囲は「公開する列の全体」で固定してください。

5.2 match_optimal() の可否を、件数で判定してしまう

match_optimal() が過小報告に転ぶ条件は、 公開データに元データに居ないレコードが混ざっていることです。 件数が一致するかどうかではありません。 引用元(vignette「再識別リスクの測り方」§3.5)のフィクスチャは RAW 150 件・公開 150 件と件数が一致した状態で逆転しています。

2.5 のサンプリングは元データの部分集合を取り出しただけなので、 件数は一致しませんが、この条件には当てはまりません。

## 判定に使うのは件数ではなく「元データに居ないレコードが混ざっているか」
c(元データ件数 = nrow(raw),
  公開件数     = nrow(S5),
  元データに居ない公開レコード = sum(!S5$ROW_NUMBER %in% raw$ROW_NUMBER))
#>                 元データ件数                     公開件数 
#>                          300                          150 
#> 元データに居ない公開レコード 
#>                            0

sc5 <- combine_scores(normalize_scores(c(list(L5$qi), L5$num), "zscore"))
mm  <- c(greedy  = mean(vapply(1:20, function(s)
                    mean(match_greedy(sc5,  seed = s)$RESULT), numeric(1))),
         optimal = mean(vapply(1:20, function(s)
                    mean(match_optimal(sc5, seed = s)$RESULT), numeric(1))))
round(mm, 4)
#>  greedy optimal 
#>  0.5237  0.5720

件数は 150 対 300 で一致していませんが、 match_optimal() の方が高く出ます(0.5720 対 0.5237)。 公開レコードの相手は必ず元データに居るので、1 対 1 制約が正しく働きます。

件数の一致・不一致で判定すると、本当に危険な場面を見逃します。 危ないのは「公開データに、元データには居ない人が混ざっている」場合で、 そこでは件数はいくらでも一致しえます。 重なりが分からないときは、match_greedy() を参照値として併記してください (この文書の測定はすべて match_greedy() 経路です)。


まとめ — 加工の効果を報告するときに書くこと

項目 なぜ必要か
加工前のベースライン 下がったことを言うには基準が要る(§1.4)
各段階の測定値(累積デルタ) 積み上げの経過。ただし順序の関数(§2.6 / §2.7)
加工ごとの leave-one-out 「どの手が効いたか」「外してよいか」に順序非依存で答える(§2.7)
測定に使った列の範囲 加工した列だけで測ると必ず効いて見える(§5.1)
攻撃者知識の想定 同じデータが想定次第で違う数値になる(§3)
最大リスクとレコード別分布 平均は最悪ケースを隠す(§4.2)
ユニシティを測った側(RAW か公開表か) 公開表側で測ると逆向きに動く(§2.5)

参考

  • vignette「再識別リスクの測り方 — 数値が意味を持つ条件」 — 測り方の誤りが どのように低い数値として現れるか
  • docs/lessons-learned.md — 想定と実測が食い違った事例の記録
  • ?generalize_value, ?score_containment, ?containment_counts — 一般化した値を扱う関数の仕様