加工して、攻撃して、結果を読む — データ保有者のための一連の手順
Source:vignettes/anonymize-attack-evaluate.Rmd
anonymize-attack-evaluate.Rmdこの文書は、自分のデータを加工し、それを攻撃し、出てきた数値を読むという 一連の作業を、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, ZG051.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.98AGE ZIP SEX の 3 列だけで、300
人中ほぼ全員が一意に決まっています。
2. 加工する
ここから 5 種類の加工を積み上げて適用します。 各段階は前の段階の出力を加工したもので、後戻りはしません。
2.1 直接識別子を落とす
最初にやることは決まっています。ID と
FINGERPRINT を消します。
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.0100000SPEND_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.1190223score_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読み取れること。
- 直接識別子の削除は、単独では効果 0 でした。 想定どおりでしたが、 「ほとんど効かない」ではなく「まったく効かない」という結果です。
-
準識別子だけを測ると(
qi_only列)、一般化と抑止は大きく効いて見えます。 全列で測ったall列の動きとは別物です。 測る範囲を加工した列に合わせると、加工は必ず効いて見えます(§5.1)。 - サンプリングの効果は小さく、しかもこの差は母数が 300 件から 150 件に減った上で測った値です(§2.5)。
- 最大リスクは全段階を通して 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.0484delta
は「その加工がなければ成功率はここまで戻っていた」という値です。
累積デルタで 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 mean の sd と range
が、同点をどう崩すかで動く幅です。 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.56988265. この作業でやりがちな間違い
詳しくは 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— 一般化した値を扱う関数の仕様