Практика · Тема 40. Межі й етика CV¶

Лекція: lecture.html · Тест: quiz.html · Домашнє завдання: homework.html

⏱ Зошит навчає близько сімдесяти маленьких мереж. Заміряно: від трьох до пʼяти хвилин на чотирьох ядрах без відеокарти, в один потік. Розбіг — від завантаження машини, а не від самого зошита.

Шість замірів, і кожен друкує числа, які стоять у лекції:

  1. Ярлик у даних. Клас корелює з яскравістю тла. Скільки коштує ця кореляція, коли на перевірці її розірвано, і чи рятує довше навчання.
  2. Рідкісний клас. Загальна точність проти точності на класі, якого в навчанні 1.5 %. І окремо — чи здатна легка задача взагалі показати несправедливість.
  3. Ліки. Зважена крос-ентропія: що вона піднімає, що опускає й де в неї оптимум.
  4. Суперечність із темою 29. Там зважування зашкодило, тут допомогло. Ми відтворюємо обидві відповіді на тих самих даних і знаходимо, чим вони різняться.
  5. Приватність. Наскільки надійно стороння людина відрізнить приклад із навчальної вибірки від нового — лише за впевненістю моделі.
  6. Атака й фейки. Скільки яскравості треба додати, щоб зламати класифікатор, і чи переносить детектор фейків свою якість на чужий генератор.
In [1]:
import time
NOTEBOOK_STARTED = time.time()

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import matplotlib.pyplot as plt

# один потік: на маленьких мережах кілька потоків не пришвидшують, а сповільнюють
# і роблять результат недетермінованим (урок блоку 2 курсу)
torch.set_num_threads(1)

print("torch :", torch.__version__)
print("numpy :", np.__version__)
print("потоків:", torch.get_num_threads())
/home/dmitro/.local/lib/python3.12/site-packages/torch/cuda/__init__.py:65: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
  import pynvml  # type: ignore[import]
torch : 2.10.0+cpu
numpy : 2.5.1
потоків: 1

Датасет: ті самі шість фігур 28×28¶

Генератор дослівно той, що в темі 16: коло, квадрат, ромб, кільце, хрест, трикутник. Центр і радіус випадкові, поверх — гаусів шум.

Рівень шуму — єдине, що ми міняємо. 0.06 дає легку задачу, 0.45 — важку. Наприкінці ти побачиш, що ця одна цифра вирішує, чи побачить замір несправедливість.

In [2]:
SHAPE_NAMES = ["коло", "квадрат", "ромб", "кільце", "хрест", "трикутник"]


def draw_shape(kind, rng, size=28, jitter=5, noise=0.06, center=None, radius=None):
    """Малює одну фігуру як масив 28×28 зі значеннями 0..1."""
    image = np.zeros((size, size), dtype=np.float32)
    if center is None:
        center_y = size / 2 + rng.integers(-jitter, jitter + 1)
        center_x = size / 2 + rng.integers(-jitter, jitter + 1)
    else:
        center_y, center_x = center
    if radius is None:
        radius = rng.integers(5, 9)

    # відстані кожного пікселя від центра — з них складаються всі шість фігур
    yy, xx = np.mgrid[0:size, 0:size]
    dy, dx = yy - center_y, xx - center_x

    if kind == 0:                                    # коло
        image[dy * dy + dx * dx <= radius * radius] = 1.0
    elif kind == 1:                                  # квадрат
        image[(np.abs(dy) <= radius * 0.85) & (np.abs(dx) <= radius * 0.85)] = 1.0
    elif kind == 2:                                  # ромб
        image[np.abs(dy) + np.abs(dx) <= radius] = 1.0
    elif kind == 3:                                  # кільце
        distance = dy * dy + dx * dx
        image[(distance <= radius * radius) & (distance >= (radius - 3) ** 2)] = 1.0
    elif kind == 4:                                  # хрест
        image[(np.abs(dy) <= 2) & (np.abs(dx) <= radius)] = 1.0
        image[(np.abs(dx) <= 2) & (np.abs(dy) <= radius)] = 1.0
    else:                                            # трикутник
        image[(dy >= -radius * 0.8) & (dy <= radius * 0.8)
              & (np.abs(dx) <= (dy + radius * 0.8) * 0.6)] = 1.0

    if noise > 0:
        image = image + rng.normal(0, noise, image.shape).astype(np.float32)
    return np.clip(image, 0, 1)


rng = np.random.default_rng(0)
figure, axes = plt.subplots(2, 6, figsize=(9, 3.2))
for kind in range(6):
    axes[0, kind].imshow(draw_shape(kind, rng, noise=0.06), cmap="gray", vmin=0, vmax=1)
    axes[1, kind].imshow(draw_shape(kind, rng, noise=0.45), cmap="gray", vmin=0, vmax=1)
    axes[0, kind].set_title(SHAPE_NAMES[kind], fontsize=9)
    axes[0, kind].axis("off")
    axes[1, kind].axis("off")
axes[0, 0].set_ylabel("шум 0.06")
plt.tight_layout()
plt.show()
print("верхній рядок — шум 0.06 (легка задача), нижній — шум 0.45 (важка)")
No description has been provided for this image
верхній рядок — шум 0.06 (легка задача), нижній — шум 0.45 (важка)

Мережа й навчання¶

Одна маленька згорткова мережа на всі заміри: три блоки «згортка → ReLU → пулінг» і лінійна голова. Вона навмисне мала — заміри треба прогнати шістдесят разів, а не один.

In [3]:
def conv_block(in_channels, out_channels):
    """Типовий блок згорткової мережі: згортка → ReLU → пулінг удвічі."""
    return nn.Sequential(
        nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1),
        nn.ReLU(),
        nn.MaxPool2d(2),
    )


class ShapeNet(nn.Module):
    """1×28×28 → 144 числа → n_classes."""

    def __init__(self, n_classes=6):
        super().__init__()
        self.body = nn.Sequential(conv_block(1, 4), conv_block(4, 8),
                                  conv_block(8, 16), nn.Flatten())
        self.head = nn.Linear(144, n_classes)

    def forward(self, x):
        return self.head(self.body(x))


def train(x, y, seed, epochs, lr=6e-3, batch=64, weights=None, n_classes=6):
    """Навчає мережу з нуля. Зерно керує і початковими вагами, і порядком батчів."""
    torch.manual_seed(seed)
    model = ShapeNet(n_classes)
    optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
    loss_function = nn.CrossEntropyLoss(weight=weights)
    shuffler = torch.Generator().manual_seed(seed)
    for _ in range(epochs):
        order = torch.randperm(len(x), generator=shuffler)
        for start in range(0, len(x), batch):
            batch_idx = order[start:start + batch]
            optimizer.zero_grad()
            loss_function(model(x[batch_idx]), y[batch_idx]).backward()
            optimizer.step()
    model.eval()
    return model


@torch.no_grad()
def predict(model, x):
    """Передбачені класи без градієнтів."""
    return model(x).argmax(1)


def accuracy(model, x, y):
    return (predict(model, x) == y).float().mean().item()


demo = ShapeNet(6)
print("параметрів у мережі:", sum(p.numel() for p in demo.parameters()))
параметрів у мережі: 2374

Замір 1 · Ярлик вивчається лише тоді, коли він бездоганний¶

Додаємо до кожного зображення ярлик, якого в задачі немає: тло фарбується в яскравість, що залежить від класу. Шість класів — шість рівнів від 0.10 до 0.60.

Ярлик не ідеальний: параметр узгодженість каже, у якої частки прикладів тло відповідає своєму класу. Решті дістається чуже тло. Узгодженість 1.00 означає «жодного винятку».

Перевіряємо на двох наборах:

  • той самий світ — тло так само підказує клас;
  • розірваний звʼязок — тло випадкове й не каже про клас нічого.
In [4]:
BACKGROUND_LEVELS = np.linspace(0.10, 0.60, 6).astype(np.float32)


def make_tagged(count, rng, consistency, noise=0.06):
    """Фігури з кольоровим тлом. consistency=None — тло не повʼязане з класом."""
    images = np.zeros((count, 1, 28, 28), dtype=np.float32)
    labels = np.zeros(count, dtype=np.int64)
    for i in range(count):
        kind = i % 6
        shape = draw_shape(kind, rng, noise=0.0)          # чиста форма, без шуму
        if consistency is None:
            level = BACKGROUND_LEVELS[rng.integers(0, 6)]
        elif rng.random() < consistency:
            level = BACKGROUND_LEVELS[kind]               # тло «свого» класу
        else:
            others = [k for k in range(6) if k != kind]
            level = BACKGROUND_LEVELS[others[rng.integers(0, 5)]]
        # фігура лишається білою, тло дістає свій рівень яскравості
        image = shape + (1 - shape) * level
        image = image + rng.normal(0, noise, image.shape).astype(np.float32)
        images[i, 0] = np.clip(image, 0, 1)
        labels[i] = kind
    return torch.from_numpy(images), torch.from_numpy(labels)


rng = np.random.default_rng(1)
x_demo, y_demo = make_tagged(6, rng, 1.00)
figure, axes = plt.subplots(1, 6, figsize=(9, 1.9))
for i in range(6):
    axes[i].imshow(x_demo[i, 0], cmap="gray", vmin=0, vmax=1)
    axes[i].set_title(SHAPE_NAMES[y_demo[i]], fontsize=9)
    axes[i].axis("off")
plt.tight_layout()
plt.show()
print("рівні тла за класами:", " ".join("%.2f" % v for v in BACKGROUND_LEVELS))
print("при узгодженості 1.00 тло називає клас точніше, ніж сама фігура")
No description has been provided for this image
рівні тла за класами: 0.10 0.20 0.30 0.40 0.50 0.60
при узгодженості 1.00 тло називає клас точніше, ніж сама фігура
In [5]:
def train_with_checkpoints(x, y, seed, epochs, checkpoints, measure, lr=6e-3, batch=64):
    """Навчає один раз, але міряє в кількох точках — щоб не платити за кожну окремо."""
    torch.manual_seed(seed)
    model = ShapeNet(6)
    optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
    loss_function = nn.CrossEntropyLoss()
    shuffler = torch.Generator().manual_seed(seed)
    result = {}
    for epoch in range(1, epochs + 1):
        order = torch.randperm(len(x), generator=shuffler)
        for start in range(0, len(x), batch):
            batch_idx = order[start:start + batch]
            optimizer.zero_grad()
            loss_function(model(x[batch_idx]), y[batch_idx]).backward()
            optimizer.step()
        if epoch in checkpoints:
            model.eval()
            result[epoch] = measure(model)
            model.train()
    return result


started = time.time()
SHORTCUT_EPOCHS = [12, 32]
shortcut = {}

for consistency in (0.50, 0.80, 0.95, 1.00):
    collected = {epoch: [] for epoch in SHORTCUT_EPOCHS}
    for seed in range(3):
        rng = np.random.default_rng(100 + seed)
        x_train, y_train = make_tagged(600, rng, consistency)
        x_same, y_same = make_tagged(300, rng, consistency)      # той самий світ
        x_broken, y_broken = make_tagged(300, rng, None)         # звʼязок розірвано
        measured = train_with_checkpoints(
            x_train, y_train, seed, max(SHORTCUT_EPOCHS), SHORTCUT_EPOCHS,
            lambda model: (accuracy(model, x_same, y_same),
                           accuracy(model, x_broken, y_broken)))
        for epoch in SHORTCUT_EPOCHS:
            collected[epoch].append(measured[epoch])
    shortcut[consistency] = {e: np.array(v) for e, v in collected.items()}

print("%-14s %-10s %-10s %-10s %s" %
      ("узгодженість", "епох", "той світ", "без ярлика", "падіння"))
print("-" * 60)
for consistency in (0.50, 0.80, 0.95, 1.00):
    for epoch in SHORTCUT_EPOCHS:
        same, broken = shortcut[consistency][epoch].mean(0)
        print("%-14.2f %-10d %-10.4f %-10.4f %+.4f" %
              (consistency, epoch, same, broken, same - broken))
print("\nчас заміру: %.0f с" % (time.time() - started))
узгодженість   епох       той світ   без ярлика падіння
------------------------------------------------------------
0.50           12         0.6756     0.5578     +0.1178
0.50           32         0.9444     0.9311     +0.0133
0.80           12         0.8100     0.3000     +0.5100
0.80           32         0.9167     0.6378     +0.2789
0.95           12         0.9556     0.2356     +0.7200
0.95           32         0.9644     0.3478     +0.6167
1.00           12         0.9922     0.1822     +0.8100
1.00           32         0.9978     0.1822     +0.8156

час заміру: 52 с
In [6]:
# розкид по зернах: різниця, менша за розкид, не є різницею (правило теми 26)
print("точність без ярлика по зернах, 32 епохи:")
for consistency in (0.50, 0.80, 0.95, 1.00):
    values = shortcut[consistency][32][:, 1]
    print("  узгодженість %.2f: %s   середнє %.4f ±%.4f" %
          (consistency, [round(float(v), 3) for v in values],
           values.mean(), values.std()))

gain_080 = shortcut[0.80][32][:, 1].mean() - shortcut[0.80][12][:, 1].mean()
gain_100 = shortcut[1.00][32][:, 1].mean() - shortcut[1.00][12][:, 1].mean()
print("\nщо дали двадцять зайвих епох:")
print("  узгодженість 0.80: %+.4f" % gain_080)
print("  узгодженість 1.00: %+.4f" % gain_100)
print("рівень вгадування на шести класах: %.4f" % (1 / 6))
точність без ярлика по зернах, 32 епохи:
  узгодженість 0.50: [0.917, 0.943, 0.933]   середнє 0.9311 ±0.0110
  узгодженість 0.80: [0.567, 0.757, 0.59]   середнє 0.6378 ±0.0846
  узгодженість 0.95: [0.253, 0.457, 0.333]   середнє 0.3478 ±0.0836
  узгодженість 1.00: [0.17, 0.207, 0.17]   середнє 0.1822 ±0.0173

що дали двадцять зайвих епох:
  узгодженість 0.80: +0.3378
  узгодженість 1.00: +0.0000
рівень вгадування на шести класах: 0.1667

Що тут сталося. При узгодженості 1.00 модель не вчить форму ніколи: двадцять зайвих епох не змінюють нічого. Причина проста — на своїх даних вона вже майже без помилок, тож похідна, яка штовхала б її вчити фігуру, дорівнює нулю.

Досить одного відсотка винятків (узгодженість 0.95, і тим паче 0.80), і картина інша: модель усе одно спершу хапається за ярлик, але виняткові приклади не дають їй заспокоїтись, і з часом форма таки вивчається.

Практичний висновок неочевидний: небезпечна не «перекошена вибірка» взагалі, а вибірка без жодного винятку.

Замір 2 · Середнє ховає нуль¶

Тепер без ярликів. Один із шести класів робимо рідкісним і дивимось на два числа: загальну точність і точність на цьому класі. Перевірка завжди збалансована — по 100 прикладів кожного класу.

Спершу треба вибрати, який клас робити рідкісним. Якщо взяти найважчий, ми не відрізнимо «рідкісний» від «важкий», тож беремо клас середньої складності.

In [7]:
def make_imbalanced(total, rng, share, rare_class, noise):
    """Навчальна вибірка, де rare_class становить share від усього."""
    n_rare = int(round(share * total))
    n_other = (total - n_rare) // 5
    plan = []
    for kind in range(6):
        plan += [kind] * (n_rare if kind == rare_class else n_other)
    images = np.zeros((len(plan), 1, 28, 28), dtype=np.float32)
    labels = np.zeros(len(plan), dtype=np.int64)
    for i, kind in enumerate(plan):
        images[i, 0] = draw_shape(kind, rng, noise=noise)
        labels[i] = kind
    return torch.from_numpy(images), torch.from_numpy(labels), n_rare


def make_balanced(count, rng, noise):
    """Рівно по шостій частині кожного класу."""
    images = np.zeros((count, 1, 28, 28), dtype=np.float32)
    labels = np.zeros(count, dtype=np.int64)
    for i in range(count):
        kind = i % 6
        images[i, 0] = draw_shape(kind, rng, noise=noise)
        labels[i] = kind
    return torch.from_numpy(images), torch.from_numpy(labels)


HARD_NOISE = 0.45
HARD_EPOCHS = 30
TRAIN_SIZE = 500
RARE_CLASS = 1                       # квадрат — обґрунтування нижче

started = time.time()
per_class = np.zeros((3, 6))
overall_balanced = []
for seed in range(3):
    rng = np.random.default_rng(300 + seed)
    x_train, y_train, _ = make_imbalanced(TRAIN_SIZE, rng, 1 / 6, RARE_CLASS, HARD_NOISE)
    x_test, y_test = make_balanced(600, rng, HARD_NOISE)
    model = train(x_train, y_train, seed, HARD_EPOCHS)
    guess = predict(model, x_test)
    overall_balanced.append((guess == y_test).float().mean().item())
    for kind in range(6):
        per_class[seed, kind] = (guess[y_test == kind] == kind).float().mean().item()

print("збалансоване навчання на важкій задачі, 3 зерна")
print("загальна точність: %.4f ±%.4f\n" % (np.mean(overall_balanced), np.std(overall_balanced)))
for kind in range(6):
    print("  %-11s %.4f ±%.4f" % (SHAPE_NAMES[kind], per_class[:, kind].mean(),
                                  per_class[:, kind].std()))
order = np.argsort(per_class.mean(0))
place = int(np.where(order == RARE_CLASS)[0][0]) + 1
print("\nнайлегший клас: %s (%.4f), найважчий: %s (%.4f)" %
      (SHAPE_NAMES[order[-1]], per_class[:, order[-1]].mean(),
       SHAPE_NAMES[order[0]], per_class[:, order[0]].mean()))
print("рідкісним робимо «%s»: %.4f, це %d-й із шести за складністю — не край" %
      (SHAPE_NAMES[RARE_CLASS], per_class[:, RARE_CLASS].mean(), place))
print("час заміру: %.0f с" % (time.time() - started))
збалансоване навчання на важкій задачі, 3 зерна
загальна точність: 0.5906 ±0.0096

  коло        0.4167 ±0.1893
  квадрат     0.4833 ±0.1156
  ромб        0.3700 ±0.0294
  кільце      0.9200 ±0.0283
  хрест       0.5967 ±0.1715
  трикутник   0.7567 ±0.0450

найлегший клас: кільце (0.9200), найважчий: ромб (0.3700)
рідкісним робимо «квадрат»: 0.4833, це 3-й із шести за складністю — не край
час заміру: 8 с
In [8]:
started = time.time()
SHARES = [1 / 6, 0.10, 0.03, 0.015]
imbalance = {}

for share in SHARES:
    overall, rare = [], []
    for seed in range(3):
        rng = np.random.default_rng(300 + seed)
        x_train, y_train, n_rare = make_imbalanced(TRAIN_SIZE, rng, share,
                                                   RARE_CLASS, HARD_NOISE)
        x_test, y_test = make_balanced(600, rng, HARD_NOISE)
        model = train(x_train, y_train, seed, HARD_EPOCHS)
        guess = predict(model, x_test)
        overall.append((guess == y_test).float().mean().item())
        rare.append((guess[y_test == RARE_CLASS] == RARE_CLASS).float().mean().item())
    imbalance[share] = (np.array(overall), np.array(rare), n_rare)

print("важка задача: шум %.2f, %d прикладів, %d епох, 3 зерна\n" %
      (HARD_NOISE, TRAIN_SIZE, HARD_EPOCHS))
print("%-12s %-8s %-12s %-20s %s" %
      ("частка", "штук", "на всьому", "на рідкісному", "розрив"))
print("-" * 66)
for share in SHARES:
    overall, rare, n_rare = imbalance[share]
    print("%-12s %-8d %-12.4f %.4f ±%-12.4f %+.4f" %
          ("%.1f %%" % (100 * share), n_rare, overall.mean(),
           rare.mean(), rare.std(), overall.mean() - rare.mean()))
print("\nпо зернах при 1.5 %%:", [round(float(v), 4) for v in imbalance[0.015][1]])
print("час заміру: %.0f с" % (time.time() - started))
важка задача: шум 0.45, 500 прикладів, 30 епох, 3 зерна

частка       штук     на всьому    на рідкісному        розрив
------------------------------------------------------------------
16.7 %       83       0.5906       0.4833 ±0.1156       +0.1072
10.0 %       50       0.6317       0.3500 ±0.2061       +0.2817
3.0 %        15       0.5861       0.0467 ±0.0205       +0.5394
1.5 %        8        0.5767       0.0467 ±0.0591       +0.5300

по зернах при 1.5 %%: [0.0, 0.13, 0.01]
час заміру: 30 с

А тепер те саме на легкій задачі¶

Це найважливіша клітинка розділу. Ті самі 500 прикладів, та сама частка 1.5 %, той самий бюджет навчання — міняється лише рівень шуму: 0.45 на 0.06.

In [9]:
started = time.time()
EASY_NOISE = 0.06
easy = {}

for share in SHARES:
    overall, rare = [], []
    for seed in range(3):
        rng = np.random.default_rng(300 + seed)
        x_train, y_train, n_rare = make_imbalanced(TRAIN_SIZE, rng, share,
                                                   RARE_CLASS, EASY_NOISE)
        x_test, y_test = make_balanced(600, rng, EASY_NOISE)
        model = train(x_train, y_train, seed, HARD_EPOCHS)
        guess = predict(model, x_test)
        overall.append((guess == y_test).float().mean().item())
        rare.append((guess[y_test == RARE_CLASS] == RARE_CLASS).float().mean().item())
    easy[share] = (np.array(overall), np.array(rare), n_rare)

print("легка задача: шум %.2f, решта без змін\n" % EASY_NOISE)
print("%-12s %-8s %-12s %s" % ("частка", "штук", "на всьому", "на рідкісному"))
print("-" * 52)
for share in SHARES:
    overall, rare, n_rare = easy[share]
    print("%-12s %-8d %-12.4f %.4f ±%.4f" %
          ("%.1f %%" % (100 * share), n_rare, overall.mean(), rare.mean(), rare.std()))

hard_rare = imbalance[0.015][1].mean()
easy_rare = easy[0.015][1].mean()
print("\nті самі %d прикладів рідкісного класу дають:" % easy[0.015][2])
print("  на важкій задачі: %.4f" % hard_rare)
print("  на легкій задачі: %.4f" % easy_rare)
print("  різниця тільки від складності задачі: %+.4f" % (easy_rare - hard_rare))
print("час заміру: %.0f с" % (time.time() - started))
легка задача: шум 0.06, решта без змін

частка       штук     на всьому    на рідкісному
----------------------------------------------------
16.7 %       83       0.9928       0.9900 ±0.0141
10.0 %       50       0.9811       0.9700 ±0.0424
3.0 %        15       0.9378       0.6833 ±0.0573
1.5 %        8        0.8972       0.4767 ±0.1470

ті самі 8 прикладів рідкісного класу дають:
  на важкій задачі: 0.0467
  на легкій задачі: 0.4767
  різниця тільки від складності задачі: +0.4300
час заміру: 29 с
In [10]:
figure, axes = plt.subplots(1, 2, figsize=(9.5, 3.4))
shares_percent = [100 * s for s in SHARES]
axes[0].plot(shares_percent, [imbalance[s][0].mean() for s in SHARES],
             "o-", label="на всьому")
axes[0].plot(shares_percent, [imbalance[s][1].mean() for s in SHARES],
             "s-", label="на рідкісному класі")
axes[0].axhline(1 / 6, ls=":", color="gray")
axes[0].set_xlabel("частка рідкісного класу, %")
axes[0].set_ylabel("точність")
axes[0].set_title("важка задача (шум 0.45)", fontsize=10)
axes[0].legend(fontsize=8)
axes[0].set_ylim(0, 1)

axes[1].bar(["важка\nна всьому", "важка\nрідкісний", "легка\nна всьому", "легка\nрідкісний"],
            [imbalance[0.015][0].mean(), hard_rare, easy[0.015][0].mean(), easy_rare],
            color=["#5b6b7c", "#c2185b", "#5b6b7c", "#c2185b"])
axes[1].set_title("частка 1.5 %: те саме число прикладів", fontsize=10)
axes[1].set_ylim(0, 1)
plt.tight_layout()
plt.show()
print("ліворуч — розрив росте, коли частка падає нижче за рівномірну 16.7 %")
print("праворуч — легка задача ховає несправедливість не гірше за усереднену метрику")
No description has been provided for this image
ліворуч — розрив росте, коли частка падає нижче за рівномірну 16.7 %
праворуч — легка задача ховає несправедливість не гірше за усереднену метрику

Замір 3 · Ліки: зважена крос-ентропія¶

Найпростіші ліки: сказати функції втрат, що помилка на рідкісному класі коштує дорожче. Вага 1/частота вирівнює внесок класів; ми беремо ще й навмисне завелику вагу, щоб побачити, де ліки перестають лікувати.

Міряємо чотири числа, а не одне:

  • точність на всьому — те, що зазвичай друкують;
  • повнота рідкісного класу — яку частку його прикладів модель знайшла;
  • точність влучань — яка частка тих, кого вона назвала рідкісним, справді ним є;
  • IoU рідкісного класу — метрика, що карає і за пропуски, і за хибні спрацювання.
In [11]:
def rare_stats(guess, y_true, rare_class):
    """Повертає (точність на всьому, повнота, точність влучань, IoU) для рідкісного класу."""
    hit = int(((guess == rare_class) & (y_true == rare_class)).sum())
    false_alarm = int(((guess == rare_class) & (y_true != rare_class)).sum())
    missed = int(((guess != rare_class) & (y_true == rare_class)).sum())
    recall = hit / max(1, hit + missed)
    precision = hit / max(1, hit + false_alarm)
    iou = hit / max(1, hit + false_alarm + missed)
    return (guess == y_true).float().mean().item(), recall, precision, iou


started = time.time()
CURE_SHARE = 0.03
cures = {}

for name in ("без ваг", "1/частота", "вага ×20"):
    rows = []
    for seed in range(5):                       # пʼять зерен: розкид тут великий
        rng = np.random.default_rng(300 + seed)
        x_train, y_train, n_rare = make_imbalanced(TRAIN_SIZE, rng, CURE_SHARE,
                                                   RARE_CLASS, HARD_NOISE)
        x_test, y_test = make_balanced(600, rng, HARD_NOISE)
        weights = None
        if name == "1/частота":
            counts = torch.bincount(y_train, minlength=6).float()
            weights = counts.sum() / (6 * counts)
        elif name == "вага ×20":
            weights = torch.ones(6)
            weights[RARE_CLASS] = 20.0
        model = train(x_train, y_train, seed, HARD_EPOCHS, weights=weights)
        rows.append(rare_stats(predict(model, x_test), y_test, RARE_CLASS))
    cures[name] = np.array(rows)

counts = torch.bincount(y_train, minlength=6).float()
ratio = float((counts.sum() / (6 * counts))[RARE_CLASS] / (counts.sum() / (6 * counts))[0])
print("частка рідкісного класу %.1f %% (%d прикладів), вага 1/частота = ×%.1f\n" %
      (100 * CURE_SHARE, n_rare, ratio))
print("%-12s %-14s %-14s %-14s %s" %
      ("ліки", "на всьому", "повнота", "влучань", "IoU"))
print("-" * 68)
for name, rows in cures.items():
    print("%-12s %.4f ±%-5.4f %.4f ±%-5.4f %-14.4f %.4f" %
          (name, rows[:, 0].mean(), rows[:, 0].std(), rows[:, 1].mean(),
           rows[:, 1].std(), rows[:, 2].mean(), rows[:, 3].mean()))
print("\nповнота по пʼятьох зернах:")
for name, rows in cures.items():
    print("  %-12s %s" % (name, [round(float(v), 2) for v in rows[:, 1]]))
print("час заміру: %.0f с" % (time.time() - started))
частка рідкісного класу 3.0 % (15 прикладів), вага 1/частота = ×6.5

ліки         на всьому      повнота        влучань        IoU
--------------------------------------------------------------------
без ваг      0.6063 ±0.0384 0.0940 ±0.0850 0.6479         0.0876
1/частота    0.6243 ±0.0657 0.2920 ±0.0889 0.6162         0.2440
вага ×20     0.5300 ±0.1058 0.4120 ±0.1532 0.5169         0.2873

повнота по пʼятьох зернах:
  без ваг      [0.07, 0.05, 0.02, 0.26, 0.07]
  1/частота    [0.4, 0.31, 0.15, 0.36, 0.24]
  вага ×20     [0.51, 0.45, 0.32, 0.61, 0.17]
час заміру: 49 с

Суперечність із темою 29 — і те, чим вона пояснюється¶

У темі 29 зважена крос-ентропія зашкодила: mIoU 0.4181 проти 0.6151 без ваг (це число з тієї теми, ми його тут не рахуємо). Щойно ми дістали протилежне. Два чесні заміри в одному курсі не можуть суперечити один одному просто так — отже, десь між ними ховається різниця постановки.

Знайдемо її заміром, а не міркуванням: зробимо на наших фігурах задачу сегментації (кожен піксель — предмет чи тло) і прожену вагу від ×1 до ×20.

In [12]:
def make_segmentation(count, rng, noise=0.45, radius_min=3, radius_max=6):
    """Маленькі фігури на великому тлі: предмет займає кілька відсотків пікселів."""
    images = np.zeros((count, 1, 28, 28), dtype=np.float32)
    masks = np.zeros((count, 28, 28), dtype=np.int64)
    for i in range(count):
        kind = i % 6
        radius = int(rng.integers(radius_min, radius_max))
        clean = draw_shape(kind, rng, noise=0.0, radius=radius)
        masks[i] = (clean > 0.5).astype(np.int64)          # істина за побудовою
        noisy = clean + rng.normal(0, noise, clean.shape).astype(np.float32)
        images[i, 0] = np.clip(noisy, 0, 1)
    return torch.from_numpy(images), torch.from_numpy(masks)


class SegNet(nn.Module):
    """Без стискання: на виході два числа на кожен піксель."""

    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(),
            nn.Conv2d(8, 8, 3, padding=1), nn.ReLU(),
            nn.Conv2d(8, 2, 1))

    def forward(self, x):
        return self.net(x)


def train_segmentation(x, masks, seed, epochs=12, lr=6e-3, batch=32, weight=None):
    torch.manual_seed(seed)
    net = SegNet()
    optimizer = torch.optim.AdamW(net.parameters(), lr=lr)
    loss_function = nn.CrossEntropyLoss(weight=weight)
    shuffler = torch.Generator().manual_seed(seed)
    for _ in range(epochs):
        order = torch.randperm(len(x), generator=shuffler)
        for start in range(0, len(x), batch):
            batch_idx = order[start:start + batch]
            optimizer.zero_grad()
            loss_function(net(x[batch_idx]), masks[batch_idx]).backward()
            optimizer.step()
    net.eval()
    return net


@torch.no_grad()
def segmentation_stats(net, x, masks):
    """mIoU, IoU предмета, повнота, точність влучань і частка хибних пікселів на межі."""
    guess = net(x).argmax(1)
    ious = []
    for value in (0, 1):
        intersection = int(((guess == value) & (masks == value)).sum())
        union = int(((guess == value) | (masks == value)).sum())
        ious.append(intersection / max(1, union))
    hit = int(((guess == 1) & (masks == 1)).sum())
    false_alarm = int(((guess == 1) & (masks != 1)).sum())
    missed = int(((guess != 1) & (masks == 1)).sum())
    # предмет, розширений на один піксель: чи сидять хибні спрацювання на його контурі
    grown = (F.max_pool2d((masks == 1).float().unsqueeze(1), 3, stride=1, padding=1) > 0)
    grown = grown.squeeze(1)
    wrong = (guess == 1) & (masks != 1)
    on_border = float((wrong & grown).sum()) / max(1, int(wrong.sum()))
    return (float(np.mean(ious)), ious[1], hit / max(1, hit + missed),
            hit / max(1, hit + false_alarm), int(wrong.sum()) / len(x), on_border)


started = time.time()
rng = np.random.default_rng(500)
seg_train_x, seg_train_m = make_segmentation(300, rng)
seg_test_x, seg_test_m = make_segmentation(200, rng)
foreground_share = float((seg_train_m == 1).float().mean())
frequency_weight = (1 - foreground_share) / foreground_share

segmentation = {}
for name, weight_value in [("без ваг", None), ("×2", 2.0), ("×5", 5.0),
                           ("1/частота", frequency_weight), ("×20", 20.0)]:
    rows = []
    for seed in range(3):
        weight = None if weight_value is None else torch.tensor([1.0, float(weight_value)])
        net = train_segmentation(seg_train_x, seg_train_m, seed, weight=weight)
        rows.append(segmentation_stats(net, seg_test_x, seg_test_m))
    segmentation[name] = (np.array(rows), weight_value)

print("предмет займає %.2f %% пікселів, отже 1/частота = ×%.2f\n" %
      (100 * foreground_share, frequency_weight))
print("%-12s %-8s %-16s %-10s %-10s %-10s %s" %
      ("вага", "×", "mIoU", "IoU предм.", "повнота", "влучань", "хибних/кадр"))
print("-" * 84)
for name, (rows, weight_value) in segmentation.items():
    print("%-12s %-8s %.4f ±%-8.4f %-10.4f %-10.4f %-10.4f %.0f" %
          (name, "—" if weight_value is None else "%.1f" % weight_value,
           rows[:, 0].mean(), rows[:, 0].std(), rows[:, 1].mean(),
           rows[:, 2].mean(), rows[:, 3].mean(), rows[:, 4].mean()))
print("час заміру: %.0f с" % (time.time() - started))
предмет займає 6.28 % пікселів, отже 1/частота = ×14.92

вага         ×        mIoU             IoU предм. повнота    влучань    хибних/кадр
------------------------------------------------------------------------------------
без ваг      —        0.7851 ±0.2238   0.5961     0.6277     0.6311     2
×2           2.0      0.9402 ±0.0030   0.8885     0.9615     0.9214     4
×5           5.0      0.9240 ±0.0077   0.8587     0.9755     0.8778     7
1/частота    14.9     0.8885 ±0.0106   0.7941     0.9870     0.8026     12
×20          20.0     0.8787 ±0.0098   0.7766     0.9889     0.7835     13
час заміру: 22 с
In [13]:
print("куди лягають хибно зафарбовані пікселі\n")
print("%-12s %-14s %s" % ("вага", "хибних/кадр", "із них на межі предмета"))
print("-" * 52)
for name in ("×2", "1/частота", "×20"):
    rows = segmentation[name][0]
    print("%-12s %-14.0f %.4f" % (name, rows[:, 4].mean(), rows[:, 5].mean()))

best_name = max(segmentation, key=lambda k: segmentation[k][0][:, 0].mean())
print("\nнайкраще mIoU дає вага «%s»: %.4f" % (best_name, segmentation[best_name][0][:, 0].mean()))
print("вага 1/частота (×%.1f) дає %.4f — тобто вже за оптимумом" %
      (frequency_weight, segmentation["1/частота"][0][:, 0].mean()))
print("без ваг: %.4f ±%.4f" % (segmentation["без ваг"][0][:, 0].mean(),
                               segmentation["без ваг"][0][:, 0].std()))
куди лягають хибно зафарбовані пікселі

вага         хибних/кадр    із них на межі предмета
----------------------------------------------------
×2           4              0.9327
1/частота    12             0.8243
×20          13             0.8095

найкраще mIoU дає вага «×2»: 0.9402
вага 1/частота (×14.9) дає 0.8885 — тобто вже за оптимумом
без ваг: 0.7851 ±0.2238

Ось і різниця. Зважування — це обмін: повнота росте, точність влучань падає. У сегментації хибні спрацювання лягають контуром навколо предмета (чотири пʼятих із них — на межі), і для маленького предмета цей контур зʼїдає IoU швидше, ніж повнота його додає. Тому в сегментації оптимальна вага виявляється набагато меншою, ніж 1/частота, і формула, взята не думаючи, опиняється за оптимумом.

У класифікації межі немає, а перевірка збалансована — там та сама формула стоїть перед оптимумом і виглядає як чистий виграш.

⚠️ Чого ми не довели. Ми не перезапускали модель і дані теми 29, тож не можемо стверджувати, що саме цей механізм дав там 0.4181. Ми показали лише, що на наших даних той самий рецепт поводиться в двох задачах по-різному — і назвали вимірну причину різниці.

In [14]:
figure, axes = plt.subplots(1, 2, figsize=(9.5, 3.4))
names = ["без ваг", "×2", "×5", "1/частота", "×20"]
axes[0].plot(range(5), [segmentation[n][0][:, 0].mean() for n in names], "o-", label="mIoU")
axes[0].plot(range(5), [segmentation[n][0][:, 2].mean() for n in names], "s--", label="повнота")
axes[0].plot(range(5), [segmentation[n][0][:, 3].mean() for n in names], "^--", label="влучань")
axes[0].set_xticks(range(5))
axes[0].set_xticklabels(names, fontsize=8, rotation=20)
axes[0].set_title("сегментація: у ваги є оптимум", fontsize=10)
axes[0].legend(fontsize=8)
axes[0].set_ylim(0, 1.05)

cure_names = ["без ваг", "1/частота", "вага ×20"]
axes[1].plot(range(3), [cures[n][:, 0].mean() for n in cure_names], "o-", label="на всьому")
axes[1].plot(range(3), [cures[n][:, 1].mean() for n in cure_names], "s--", label="повнота")
axes[1].plot(range(3), [cures[n][:, 2].mean() for n in cure_names], "^--", label="влучань")
axes[1].set_xticks(range(3))
axes[1].set_xticklabels(cure_names, fontsize=8)
axes[1].set_title("класифікація: той самий обмін", fontsize=10)
axes[1].legend(fontsize=8)
axes[1].set_ylim(0, 1.05)
plt.tight_layout()
plt.show()
print("обидві задачі показують один обмін; різна лише позиція формули 1/частота")
No description has been provided for this image
обидві задачі показують один обмін; різна лише позиція формули 1/частота

Замір 4 · Приватність: модель памʼятає свої дані¶

Навчена модель — це не тільки правило. Це ще й слід від конкретних прикладів, на яких її навчали. Перевіримо це найпростішою з атак: чи можна за впевненістю моделі відрізнити картинку з навчальної вибірки від нової.

Міра — AUC: частка пар «свій, чужий», де свій дістав вищу впевненість. 0.5 означає «ніяк не відрізнити», 1.0 — «відрізняється завжди».

In [15]:
def membership_auc(member_scores, outsider_scores):
    """AUC вручну: частка пар, де член навчальної вибірки впевненіший за нового."""
    scores = np.concatenate([member_scores, outsider_scores])
    ranks = np.empty(len(scores))
    ranks[np.argsort(scores, kind="mergesort")] = np.arange(1, len(scores) + 1)
    n_members = len(member_scores)
    n_outsiders = len(outsider_scores)
    return (ranks[:n_members].sum() - n_members * (n_members + 1) / 2) / (n_members * n_outsiders)


@torch.no_grad()
def confidence_of_true_class(model, x, y):
    """Наскільки модель упевнена саме в правильній відповіді."""
    probabilities = torch.softmax(model(x), 1)
    return probabilities[torch.arange(len(y)), y].numpy()


# звіряємо власний рахунок із бібліотечним — щоб було видно, що магії немає
from sklearn.metrics import roc_auc_score
demo_members = np.array([0.9, 0.8, 0.7, 0.4])
demo_outsiders = np.array([0.6, 0.5, 0.3])
ours = membership_auc(demo_members, demo_outsiders)
theirs = roc_auc_score([1, 1, 1, 1, 0, 0, 0],
                       np.concatenate([demo_members, demo_outsiders]))
assert np.allclose(ours, theirs), "рахунок AUC розійшовся!"
print("наш AUC %.4f, sklearn %.4f ✅ збігається" % (ours, theirs))
наш AUC 0.8333, sklearn 0.8333 ✅ збігається
In [16]:
started = time.time()
CHECKPOINTS = [10, 30, 60]
privacy = {}

for train_size in (200, 500):
    collected = {epoch: [] for epoch in CHECKPOINTS}
    for seed in range(3):
        rng = np.random.default_rng(700 + seed)
        x_train, y_train = make_balanced(train_size, rng, HARD_NOISE)
        x_new, y_new = make_balanced(500, rng, HARD_NOISE)

        def measure(model):
            members = confidence_of_true_class(model, x_train, y_train)
            outsiders = confidence_of_true_class(model, x_new, y_new)
            return (membership_auc(members, outsiders), members.mean(),
                    outsiders.mean(), accuracy(model, x_new, y_new))

        measured = train_with_checkpoints(x_train, y_train, seed, max(CHECKPOINTS),
                                          CHECKPOINTS, measure)
        for epoch in CHECKPOINTS:
            collected[epoch].append(measured[epoch])
    privacy[train_size] = {e: np.array(v) for e, v in collected.items()}

print("%-10s %-8s %-16s %-12s %-12s %s" %
      ("прикладів", "епох", "AUC атаки", "свої", "чужі", "точність"))
print("-" * 74)
for train_size in (200, 500):
    for epoch in CHECKPOINTS:
        row = privacy[train_size][epoch].mean(0)
        spread = privacy[train_size][epoch][:, 0].std()
        print("%-10d %-8d %.4f ±%-8.4f %-12.4f %-12.4f %.4f" %
              (train_size, epoch, row[0], spread, row[1], row[2], row[3]))
print("\nAUC по зернах на 60-й епосі:")
for train_size in (200, 500):
    values = privacy[train_size][60][:, 0]
    print("  %d прикладів: %s" % (train_size, [round(float(v), 4) for v in values]))
print("\nчас заміру: %.0f с" % (time.time() - started))
прикладів  епох     AUC атаки        свої         чужі         точність
--------------------------------------------------------------------------
200        10       0.5321 ±0.0134   0.1704       0.1686       0.1867
200        30       0.5968 ±0.0424   0.4301       0.3512       0.4380
200        60       0.7041 ±0.0712   0.7622       0.4933       0.5473
500        10       0.5093 ±0.0061   0.2542       0.2502       0.3240
500        30       0.5838 ±0.0125   0.5406       0.4690       0.5613
500        60       0.6645 ±0.0489   0.9294       0.6716       0.7027

AUC по зернах на 60-й епосі:
  200 прикладів: [0.6798, 0.8009, 0.6316]
  500 прикладів: [0.6157, 0.6465, 0.7313]

час заміру: 31 с
In [17]:
rng = np.random.default_rng(700)
x_train, y_train = make_balanced(200, rng, HARD_NOISE)
x_new, y_new = make_balanced(500, rng, HARD_NOISE)
model = train(x_train, y_train, 0, 60)
members = confidence_of_true_class(model, x_train, y_train)
outsiders = confidence_of_true_class(model, x_new, y_new)

plt.figure(figsize=(7, 3.2))
plt.hist(members, bins=25, alpha=0.6, density=True, label="були в навчанні")
plt.hist(outsiders, bins=25, alpha=0.6, density=True, label="модель їх не бачила")
plt.xlabel("упевненість у правильній відповіді")
plt.ylabel("щільність")
plt.legend(fontsize=9)
plt.tight_layout()
plt.show()
print("AUC на цьому зерні: %.4f" % membership_auc(members, outsiders))
print("медіана впевненості: свої %.4f, чужі %.4f" %
      (np.median(members), np.median(outsiders)))
No description has been provided for this image
AUC на цьому зерні: 0.6798
медіана впевненості: свої 0.8715, чужі 0.5185

Замір 5 · Атака: скільки треба, щоб зламати класифікатор¶

Беремо звичайний класифікатор на легкій задачі й додаємо до картинки обурення в напрямку, де похибка росте найшвидше (це метод FGSM). Розмір обурення задає ε: до кожного пікселя додається рівно ±ε.

Щоб було видно, наскільки це мало, рахуємо ще й скільки пікселів яскравості ми насправді додали: сума модулів обурення, поділена на яскравість одного білого пікселя.

In [18]:
def fgsm_attack(model, x, y, epsilon):
    """Обурення в напрямку знака градієнта — найдешевша атака, що є."""
    attacked = x.clone().requires_grad_(True)
    loss = nn.CrossEntropyLoss()(model(attacked), y)
    loss.backward()
    return torch.clamp(attacked + epsilon * attacked.grad.sign(), 0, 1).detach()


started = time.time()
EPSILONS = [0.0, 0.01, 0.02, 0.03, 0.05, 0.10, 0.15]
attack = {epsilon: {"fgsm": [], "random": [], "pixels": []} for epsilon in EPSILONS}

for seed in range(3):
    rng = np.random.default_rng(800 + seed)
    x_train, y_train = make_balanced(900, rng, EASY_NOISE)
    x_test, y_test = make_balanced(300, rng, EASY_NOISE)
    model = train(x_train, y_train, seed, 12)
    for epsilon in EPSILONS:
        x_attacked = fgsm_attack(model, x_test, y_test, epsilon) if epsilon > 0 else x_test
        attack[epsilon]["fgsm"].append(accuracy(model, x_attacked, y_test))
        # для порівняння — випадковий шум рівно тієї самої сили
        noise_generator = torch.Generator().manual_seed(seed)
        signs = torch.randint(0, 2, x_test.shape, generator=noise_generator).float() * 2 - 1
        x_noisy = torch.clamp(x_test + epsilon * signs, 0, 1)
        attack[epsilon]["random"].append(accuracy(model, x_noisy, y_test))
        attack[epsilon]["pixels"].append(float((x_attacked - x_test).abs().sum() / len(x_test)))

print("%-8s %-14s %-18s %-16s %s" %
      ("ε", "після атаки", "випадковий шум", "пікселів з 784", "% яскравості"))
print("-" * 74)
for epsilon in EPSILONS:
    pixels = np.mean(attack[epsilon]["pixels"])
    print("%-8.2f %-14.4f %-18.4f %-16.1f %.1f %%" %
          (epsilon, np.mean(attack[epsilon]["fgsm"]),
           np.mean(attack[epsilon]["random"]), pixels, 100 * pixels / 784))
print("\nрівень вгадування: %.4f" % (1 / 6))
print("час заміру: %.0f с" % (time.time() - started))
ε        після атаки    випадковий шум     пікселів з 784   % яскравості
--------------------------------------------------------------------------
0.00     0.9956         0.9956             0.0              0.0 %
0.01     0.9778         0.9956             5.7              0.7 %
0.02     0.9400         0.9933             11.2             1.4 %
0.03     0.8822         0.9922             16.4             2.1 %
0.05     0.6578         0.9878             26.3             3.3 %
0.10     0.3511         0.9256             48.2             6.1 %
0.15     0.2022         0.6867             68.4             8.7 %

рівень вгадування: 0.1667
час заміру: 12 с
In [19]:
rng = np.random.default_rng(800)
x_train, y_train = make_balanced(900, rng, EASY_NOISE)
x_test, y_test = make_balanced(300, rng, EASY_NOISE)
model = train(x_train, y_train, 0, 12)
x_attacked = fgsm_attack(model, x_test, y_test, 0.05)
difference = (x_attacked - x_test)[0, 0].numpy()

figure, axes = plt.subplots(1, 3, figsize=(8, 2.9))
axes[0].imshow(x_test[0, 0], cmap="gray", vmin=0, vmax=1)
axes[0].set_title("оригінал: %s" % SHAPE_NAMES[y_test[0]], fontsize=9)
axes[1].imshow(difference, cmap="coolwarm", vmin=-0.05, vmax=0.05)
axes[1].set_title("обурення, ±0.05", fontsize=9)
axes[2].imshow(x_attacked[0, 0], cmap="gray", vmin=0, vmax=1)
axes[2].set_title("після атаки: %s" % SHAPE_NAMES[predict(model, x_attacked)[0]], fontsize=9)
for ax in axes:
    ax.axis("off")
plt.tight_layout()
plt.show()
print("найбільша зміна одного пікселя: %.4f яскравості" % np.abs(difference).max())
print("людське око різниці між першою й третьою картинками не бачить")
No description has been provided for this image
найбільша зміна одного пікселя: 0.0500 яскравості
людське око різниці між першою й третьою картинками не бачить

Замір 6 · Детектор фейків не переносить свою якість¶

Теми 38 і 39 вчать генерувати. Питання, якого вони не ставлять: чи можна потім відрізнити згенероване від справжнього?

Робимо два різні «генератори» з різними слідами:

  • A — мʼякий край: контур розмитий, ніби після згладжування;
  • B — шахова сітка: заливка промодульована через піксель, як після транспонованої згортки.

Детектор навчається відрізняти справжнє від фейка генератора A, а перевіряється і на A, і на B.

In [20]:
CHECKERBOARD = ((np.mgrid[0:28, 0:28].sum(0) % 2) * 2 - 1).astype(np.float32)


def generate(kind, rng, artifact, mode, noise=0.06):
    """mode='real' — чесна фігура; 'A' — мʼякий край; 'B' — шахова сітка."""
    image = draw_shape(kind, rng, noise=0.0)
    if mode == "A":
        padded = np.pad(image, 1, mode="edge")
        blurred = sum(padded[i:i + 28, j:j + 28] for i in range(3) for j in range(3)) / 9.0
        image = (1 - artifact) * image + artifact * blurred
    elif mode == "B":
        image = image * (1 + artifact * CHECKERBOARD)
    image = image + rng.normal(0, noise, image.shape).astype(np.float32)
    return np.clip(image, 0, 1).astype(np.float32)


def make_detector_set(count, rng, artifact, mode):
    """Половина справжніх, половина фейків названого генератора."""
    images = np.zeros((count, 1, 28, 28), dtype=np.float32)
    labels = np.zeros(count, dtype=np.int64)
    for i in range(count):
        kind = i % 6
        is_fake = (i % 2 == 1)
        images[i, 0] = generate(kind, rng, artifact, mode if is_fake else "real")
        labels[i] = int(is_fake)
    return torch.from_numpy(images), torch.from_numpy(labels)


rng = np.random.default_rng(950)
figure, axes = plt.subplots(1, 3, figsize=(7.5, 2.8))
for i, (mode, title) in enumerate([("real", "справжня"), ("A", "генератор A"),
                                   ("B", "генератор B")]):
    axes[i].imshow(generate(0, np.random.default_rng(950), 0.60, mode),
                   cmap="gray", vmin=0, vmax=1)
    axes[i].set_title(title, fontsize=9)
    axes[i].axis("off")
plt.tight_layout()
plt.show()
print("обидва фейки видно оком при силі артефакту 0.60 — але сліди в них різні")
No description has been provided for this image
обидва фейки видно оком при силі артефакту 0.60 — але сліди в них різні
In [21]:
started = time.time()
detector = {}
for artifact in (0.60, 0.30, 0.15):
    own, foreign = [], []
    for seed in range(3):
        rng = np.random.default_rng(900 + seed)
        x_train, y_train = make_detector_set(800, rng, artifact, "A")
        x_own, y_own = make_detector_set(400, rng, artifact, "A")
        x_foreign, y_foreign = make_detector_set(400, rng, artifact, "B")
        net = train(x_train, y_train, seed, 8, n_classes=2)
        own.append(accuracy(net, x_own, y_own))
        foreign.append(accuracy(net, x_foreign, y_foreign))
    detector[artifact] = (np.array(own), np.array(foreign))

print("детектор навчено на генераторі A\n")
print("%-16s %-18s %s" % ("сила артефакту", "свій генератор", "чужий генератор"))
print("-" * 56)
for artifact in (0.60, 0.30, 0.15):
    own, foreign = detector[artifact]
    print("%-16.2f %.4f ±%-10.4f %.4f ±%.4f" %
          (artifact, own.mean(), own.std(), foreign.mean(), foreign.std()))
print("\nрівень вгадування на двох класах: 0.5000")
print("час заміру: %.0f с" % (time.time() - started))
детектор навчено на генераторі A

сила артефакту   свій генератор     чужий генератор
--------------------------------------------------------
0.60             0.9992 ±0.0012     0.5017 ±0.0024
0.30             0.9642 ±0.0507     0.8558 ±0.1242
0.15             0.9467 ±0.0684     0.9458 ±0.0678

рівень вгадування на двох класах: 0.5000
час заміру: 14 с
In [22]:
print("загальний час зошита: %.0f с" % (time.time() - NOTEBOOK_STARTED))
загальний час зошита: 280 с

Завдання¶

🟢 Рівень 1 — База¶

Повтори замір 1 з іншим ярликом: замість яскравості тла додай у куток кожної картинки квадратик 3×3, яскравість якого залежить від класу. Зроблено, якщо: ти дістав таблицю «узгодженість × точність без ярлика» для чотирьох значень узгодженості й трьох зерен і можеш сказати, при якій узгодженості падіння вперше перевищує розкид по зернах.

🟡 Рівень 2 — Плюс¶

Замір 2 зроблено на одному рідкісному класі — квадраті. Прожени його по всіх шести класах при частці 3 % і побудуй стовпчикову діаграму «клас → точність на ньому». Зроблено, якщо: ти можеш відповісти числом, чи корелює падіння рідкісного класу з його точністю на збалансованому навчанні, і назвати клас, який страждає найбільше.

🔴 Рівень 3 — Виклик¶

Ми показали, що в зважування є оптимум, але не знайшли, де він для класифікації. Прожени вагу рідкісного класу від ×1 до ×60 із кроком і побудуй три криві: точність на всьому, повнота й IoU рідкісного класу. Зроблено, якщо: ти назвав вагу, при якій IoU рідкісного класу максимальний, показав, що вона відрізняється від 1/частота, і перевірив висновок на пʼятьох зернах.

Підказки¶

  • Розкид у цих замірах великий. Перш ніж радіти різниці 0.05, подивись на розкид по зернах — часто він більший.
  • Якщо рідкісний клас дає рівно 0.0000 на всіх зернах, перевір, чи модель узагалі колись передбачає цей клас: torch.bincount(predict(model, x_test), minlength=6).
  • Мережа маленька навмисне. Якщо збільшити її, всі числа зсунуться — і це теж результат, який варто записати.