【PATE】生徒側の実装を解説【差分プライバシ】

研究に行き詰まったのでPATEの生徒側の実装をもう一度全て確認してみる

同時に解説することで理解度を深める

## 環境設定

- 二値分類にのみ対応
- Catboostで学習
- CTRデータセット特有の操作も含まれる
- 選び方は`argmax`
- それ以外の部分は省略

## 解説

インポート部分・標準入力からの`agrs`は省略

```py
from lib.utils import (
    sample_one_vectorized,
    weighted_acc,
    optimal_pred,
    get_eps,
    get_eps_data_independent,
)

# Data
print("==> Preparing data..")
# 省略
else:
    continue_var = ["I" + str(i) for i in range(1, 14)]
    cat_features = ["C" + str(i) for i in range(1, 27)]
    trainset_path = os.path.join(args.data_path, args.dataset + "_train.csv")
    testset_path = os.path.join(args.data_path, args.dataset + "_test.csv")
    train = pd.read_csv(trainset_path)
    test = pd.read_csv(testset_path)

    y_train_original = train[["Label"]]
    x_train_original = train.drop(["Label"], axis=1)
    y_test = test[["Label"]]
    x_test = test.drop(["Label"], axis=1)
```

データの読み込み部分。全て`pd.DataFrame`型

- `y_train_original`: トレーニングデータの正解ラベル
- `x_train_original`: トレーニングデータの特徴量
- `y_test`: テストデータの正解ラベル
- `x_test`: テストデータの特徴量

```py
np.random.seed(args.seed)

x_test, x_val, y_test, y_val = train_test_split(
    x_test, y_test, test_size=0.20, stratify=y_test, random_state=256
)

student_indices = np.random.permutation(len(x_train_original))
```

- `train_test_split`: テストデータから検証データを分割
    - なお、教師データとの整合性のため、トレーニングデータから検証データを分割するのは難しい
- `stratify=y_test`: `y_test`と同じ分布を保つように分割する
- `student_indices`: ランダムなインデックスリスト

```py
# Aggregate votes
print("==> Aggregating votes..")


def get_one_teacher_vote(prob, mode):
    if mode == "argmax":
        votes = np.zeros_like(prob)
        votes[np.arange(len(prob)), prob.argmax(1)] = 1
    elif mode == "sample":
        samples = sample_one_vectorized(prob)
        votes = np.zeros_like(prob)
        votes[np.arange(len(prob)), samples] = 1
    else:
        raise NotImplementedError
    return votes
```

- `mode == argmax`の場合
    - 与えられた予想確率分布`prob`のうち最も大きいもののindexに1を格納
    - ワンホットエンコーディング

```py
teacher_votes = []
teacher_votes_test = []
for i in range(args.n_teachers):
    with open(
        args.model_path + "teacher_{0}/checkpoint_stats.pkl".format(i), "rb"
    ) as f:
        checkpoint = pickle.load(f)
    teacher_prob = checkpoint["train_original_prob"]
    teacher_prob_test = checkpoint["test_prob"]
    teacher_votes.append(get_one_teacher_vote(teacher_prob, args.tally_method))
    teacher_votes_test.append(
        get_one_teacher_vote(teacher_prob_test, args.tally_method)
    )

agg_votes = sum(teacher_votes)
agg_votes_test = sum(teacher_votes_test)
```

教師ごとにトレーニングデータが分割されているため、教師ごとに処理をしていく
なお、

- `teacher_prob`: 教師`i`におけるトレーニングデータ全体の特徴量に基づいたラベルの予測確率分布
    - `np.ndarray`型, `(n_samples, num_classes)`
- `agg_votes`: 何人の教師がデータポイント`i`をラベル`j`に属すると予測したか
    - `np.ndarray型`, `(n_samples, num_classes)`


```py
# eps = get_eps(agg_votes, args.mechanism, args.n_samples, args.result_noise, args.selection_noise, args.noise_threshold)
# print('data dependent eps is {0}'.format(eps))

eps = get_eps_data_independent(args.n_samples, args.result_noise, 1e-4)
print("data independent eps is {0}".format(eps))
```

- データ構造に依存する${\varepsilon}$と依存しない${\varepsilon}$を計算
    - 依存するver.について計算手法を理解したい

```py
class_weights = [y_train_original.mean(), 1 - y_train_original.mean()]
B = (
    y_train_original.mean() * class_weights[1]
    + (1 - y_train_original.mean()) * class_weights[0]
).item()

print("B is {0}".format(B))
```

- `class_weights`: 各クラスの出現確率の逆数を各クラスの重みとして設定
- `B`: ${2\mu _0 \mu _1}$
    - 後々正規化のために使う

```py
y_train_opt_pred = optimal_pred(agg_votes, class_weights)
advantage_train = (
    weighted_acc(y_train_original.to_numpy().squeeze(), y_train_opt_pred, class_weights)
) / B
y_test_opt_pred = optimal_pred(agg_votes_test, class_weights)
advantage_test = (
    weighted_acc(y_test.to_numpy().squeeze(), y_test_opt_pred, class_weights)
) / B
```

- `weighted_acc`: 重み付き精度
    - 正解したデータポイントにおけるラベルの重みの平均値
    - ${\text{wa} = \frac{1}{N}\sum_{i=1}^N \mathbb 1(y_i = \hat y_i)\cdot w_{y_i}}$
- B: 正規化のため変数
    - ${\text B = \sum_{k=1}^K p_{k}\cdot w_{k}}$
- `advantage_train`: トレーニングデータ全体の正解ラベルと予測ラベルの重み付き正答率
    - ${0\le \frac{\frac{1}{N}\sum_{i=1}^N \mathbb 1(y_i = \hat y_i)\cdot w_{y_i}}{\sum_{i=1}^N p_{y_i}\cdot w_{y_i}}\le 1}$
    - なぜなら全ての予測が正しいとき、Bと等しくなるから

```py
# need to reorder to align the prediction and indices
agg_votes = agg_votes[student_indices]
```

- シャッフルする

```py
def noisy_threshold_labels_custom(
    votes,
    mechanism,
    threshold,
    selection_noise_scale,
    result_noise_scale,
    mode,
    class_1_portion,
):
    def noise(scale, mechanism, shape):
        if scale == 0:
            return 0
        if mechanism.startswith("lnmax"):
            return np.random.laplace(0, scale, shape)
        elif mechanism.startswith("gnmax"):
            return np.random.normal(0, scale, shape)
        else:
            raise NotImplementedError
    if mechanism == "gnmax_conf":
        noisy_votes = votes + noise(selection_noise_scale, mechanism, votes.shape)
        over_t_mask = noisy_votes.max(axis=1) > threshold
        over_t_counts = votes[over_t_mask] + noise(
            result_noise_scale, mechanism, votes[over_t_mask].shape
        )
    else:
        noisy_votes = votes + noise(result_noise_scale, mechanism, votes.shape)
        over_t_mask = noisy_votes.max(axis=1) > float("-inf")
        over_t_counts = noisy_votes
    if mode == "argmax":
        over_t_labels = over_t_counts.argmax(axis=1)
    else:
        raise NotImplementedError

    return over_t_labels, over_t_mask
```

- 教師の投票などを受け取って、ある程度自信のあるラベルにのみノイズを加えたラベルと、自信のあるラベルのインデックスに`True`が入った配列を返す関数
- `lnmax`: ラプラスノイズ
- `gnmax`: ガウスノイズ
- 以下の、
```py
    if mechanism == "gnmax_conf":
        noisy_votes = votes + noise(selection_noise_scale, mechanism, votes.shape)
        over_t_mask = noisy_votes.max(axis=1) > threshold
        over_t_counts = votes[over_t_mask] + noise(
            result_noise_scale, mechanism, votes[over_t_mask].shape
        )
```

 の部分では、[アンサンブル](https://layerxnews.substack.com/p/60e#:~:text=%E6%95%99%E5%B8%AB%E3%82%A2%E3%83%B3%E3%82%B5%E3%83%B3%E3%83%96%E3%83%AB%E3%81%AE%E3%83%97%E3%83%A9%E3%82%A4%E3%83%99%E3%83%BC%E3%83%88%E3%81%AA%E9%9B%86%E7%B4%84%EF%BC%88PATE%EF%BC%89)を実装している


```py
labels, threshold_mask = noisy_threshold_labels_custom(
    votes=agg_votes,
    mechanism=args.mechanism,
    threshold=args.noise_threshold,
    selection_noise_scale=args.selection_noise,
    result_noise_scale=args.result_noise,
    mode=args.selection_method,
    class_1_portion=y_train_original.mean().values[0],
)

threshold_indices = threshold_mask.nonzero()[0]
indices = student_indices[threshold_indices][: args.n_samples]
labels = labels[: args.n_samples]
```

- `labels`: ノイズ付加後のラベル（シャッフル済み）
- `threshold_mask`: 教師の投票数が閾値を超えたラベルのインデックスにTrueが入った配列
    - すなわちある程度自信を持って正解しているであろうラベル
- `threshold_indices`: ある程度自信のあるラベルのインデックス
- `indices`: シャッフル後のラベルのうち、自信のあるラベルの前`n_samples`個抽出
- `labels`: ノイズ付加後の`n_samples`個のラベル
    - これ、`labels[threshold_indices]`にしないと整合性取れなくない？


```py
x_student = x_train_original.iloc[indices]
y_student_actual = y_train_original.iloc[indices]
y_student = labels
```

- `x_student`: 閾値を超えた`n_samples`個のオリジナル特徴量
- `y_student`: ノイズ付加後の`n_samples`個のラベル

```py
print("==> Training..")
checkpoint = {}
if args.algo == "catboost":
    cat_features = [col for col in train.columns if "C" in col]
    model = CatBoostClassifier(
        iterations=100,
        learning_rate=0.4,
        task_type="CPU",
        loss_function="Logloss",
        depth=8,
    )

    fit_model = model.fit(
        x_student,
        y_student,
        eval_set=(x_val, y_val),
        cat_features=cat_features,
        verbose=10,
    )

    y_test_prob = model.predict(
        x_test,
        prediction_type="Probability",
        ntree_end=model.get_best_iteration(),
        thread_count=-1,
        verbose=None,
    )

    y_val_prob = model.predict(
        x_val,
        prediction_type="Probability",
        ntree_start=0,
        ntree_end=model.get_best_iteration(),
        thread_count=-1,
        verbose=None,
    )

    y_student_prob = model.predict(
        x_student,
        prediction_type="Probability",
        ntree_start=0,
        ntree_end=model.get_best_iteration(),
        thread_count=-1,
        verbose=None,
    )

    y_train_original_prob = model.predict(
        x_train_original,
        prediction_type="Probability",
        ntree_start=0,
        ntree_end=model.get_best_iteration(),
        thread_count=-1,
        verbose=None,
    )
else:
    raise NotImplementedError
```

`Catboost`以外のコード部分は省略

- `x_student`と`y_student`を用いてノイズを付加したラベルで学習

```py
print("==> Evaluating..")

checkpoint["log_loss"] = log_loss(y_test, y_test_prob[:, 1])

print("test log loss is {0}".format(checkpoint["log_loss"]))

# TODO add if clause for logistic regression
y_student_opt_pred = optimal_pred(y_student_prob, class_weights)
checkpoint["advantage_student"] = (
    weighted_acc(
        y_student_actual.to_numpy().squeeze(), y_student_opt_pred, class_weights
    )
) / B
print(
    "advantage student dataset after training is {0}".format(
        checkpoint["advantage_student"]
    )
)
```

- `y_student_opt_pred`: ノイズ付きで学習したモデルが予測した予測確率分布に重みをかけたもののうち、最も大きい値となったラベルを選ぶ
- `advantage_student`: 実際のラベルとノイズ付きかつ重みをかけた上で最も大きいラベルがどれくらいあっているかの精度を、重み付きで計測
    - あっている部分の重みの平均値を出している
トレーニング、テストも同様なので省略

``` py
train_predictions = np.argmax(y_train_original_prob, axis=1)
train_labels = y_train_original.to_numpy().squeeze()
print(
    "train acc after training is {0}".format((train_predictions == train_labels).mean())
)
print("train prediction percent 0 is {0}".format(1 - np.mean(train_predictions)))

# テストも同様なので省略

checkpoint["eps"] = eps
```

- `train acc after training`: 純粋に最も大きい確率を選び、それが純粋にどれくらいあっているかの指標

保存関係は省略