Практика · Тема 40. Межі й етика CV¶
Лекція: lecture.html · Тест: quiz.html · Домашнє завдання: homework.html
⏱ Зошит навчає близько сімдесяти маленьких мереж. Заміряно: від трьох до пʼяти хвилин на чотирьох ядрах без відеокарти, в один потік. Розбіг — від завантаження машини, а не від самого зошита.
Шість замірів, і кожен друкує числа, які стоять у лекції:
- Ярлик у даних. Клас корелює з яскравістю тла. Скільки коштує ця кореляція, коли на перевірці її розірвано, і чи рятує довше навчання.
- Рідкісний клас. Загальна точність проти точності на класі, якого в навчанні 1.5 %. І окремо — чи здатна легка задача взагалі показати несправедливість.
- Ліки. Зважена крос-ентропія: що вона піднімає, що опускає й де в неї оптимум.
- Суперечність із темою 29. Там зважування зашкодило, тут допомогло. Ми відтворюємо обидві відповіді на тих самих даних і знаходимо, чим вони різняться.
- Приватність. Наскільки надійно стороння людина відрізнить приклад із навчальної вибірки від нового — лише за впевненістю моделі.
- Атака й фейки. Скільки яскравості треба додати, щоб зламати класифікатор, і чи переносить детектор фейків свою якість на чужий генератор.
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 — важку. Наприкінці ти побачиш, що ця одна цифра вирішує, чи побачить замір несправедливість.
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 (важка)")
верхній рядок — шум 0.06 (легка задача), нижній — шум 0.45 (важка)
Мережа й навчання¶
Одна маленька згорткова мережа на всі заміри: три блоки «згортка → ReLU → пулінг» і лінійна голова. Вона навмисне мала — заміри треба прогнати шістдесят разів, а не один.
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 означає «жодного винятку».
Перевіряємо на двох наборах:
- той самий світ — тло так само підказує клас;
- розірваний звʼязок — тло випадкове й не каже про клас нічого.
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 тло називає клас точніше, ніж сама фігура")
рівні тла за класами: 0.10 0.20 0.30 0.40 0.50 0.60 при узгодженості 1.00 тло називає клас точніше, ніж сама фігура
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 с
# розкид по зернах: різниця, менша за розкид, не є різницею (правило теми 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 прикладів кожного класу.
Спершу треба вибрати, який клас робити рідкісним. Якщо взяти найважчий, ми не відрізнимо «рідкісний» від «важкий», тож беремо клас середньої складності.
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 с
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.
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 с
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("праворуч — легка задача ховає несправедливість не гірше за усереднену метрику")
ліворуч — розрив росте, коли частка падає нижче за рівномірну 16.7 % праворуч — легка задача ховає несправедливість не гірше за усереднену метрику
Замір 3 · Ліки: зважена крос-ентропія¶
Найпростіші ліки: сказати функції втрат, що помилка на рідкісному класі коштує
дорожче. Вага 1/частота вирівнює внесок класів; ми беремо ще й навмисне завелику
вагу, щоб побачити, де ліки перестають лікувати.
Міряємо чотири числа, а не одне:
- точність на всьому — те, що зазвичай друкують;
- повнота рідкісного класу — яку частку його прикладів модель знайшла;
- точність влучань — яка частка тих, кого вона назвала рідкісним, справді ним є;
- IoU рідкісного класу — метрика, що карає і за пропуски, і за хибні спрацювання.
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.
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 с
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. Ми показали лише, що на наших даних той самий рецепт поводиться в двох задачах по-різному — і назвали вимірну причину різниці.
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/частота")
обидві задачі показують один обмін; різна лише позиція формули 1/частота
Замір 4 · Приватність: модель памʼятає свої дані¶
Навчена модель — це не тільки правило. Це ще й слід від конкретних прикладів, на яких її навчали. Перевіримо це найпростішою з атак: чи можна за впевненістю моделі відрізнити картинку з навчальної вибірки від нової.
Міра — AUC: частка пар «свій, чужий», де свій дістав вищу впевненість. 0.5 означає «ніяк не відрізнити», 1.0 — «відрізняється завжди».
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 ✅ збігається
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 с
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)))
AUC на цьому зерні: 0.6798 медіана впевненості: свої 0.8715, чужі 0.5185
Замір 5 · Атака: скільки треба, щоб зламати класифікатор¶
Беремо звичайний класифікатор на легкій задачі й додаємо до картинки обурення в напрямку, де похибка росте найшвидше (це метод FGSM). Розмір обурення задає ε: до кожного пікселя додається рівно ±ε.
Щоб було видно, наскільки це мало, рахуємо ще й скільки пікселів яскравості ми насправді додали: сума модулів обурення, поділена на яскравість одного білого пікселя.
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 с
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("людське око різниці між першою й третьою картинками не бачить")
найбільша зміна одного пікселя: 0.0500 яскравості людське око різниці між першою й третьою картинками не бачить
Замір 6 · Детектор фейків не переносить свою якість¶
Теми 38 і 39 вчать генерувати. Питання, якого вони не ставлять: чи можна потім відрізнити згенероване від справжнього?
Робимо два різні «генератори» з різними слідами:
- A — мʼякий край: контур розмитий, ніби після згладжування;
- B — шахова сітка: заливка промодульована через піксель, як після транспонованої згортки.
Детектор навчається відрізняти справжнє від фейка генератора A, а перевіряється і на A, і на B.
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 — але сліди в них різні")
обидва фейки видно оком при силі артефакту 0.60 — але сліди в них різні
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 с
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). - Мережа маленька навмисне. Якщо збільшити її, всі числа зсунуться — і це теж результат, який варто записати.