Table of Contents Табела на содржини Вовед: Етикетите се скапи, сликите се бесплатни Дел 1 – Истражување на податоци: Разбирање на податоците со кои работите Дел 2 – Пред-процесирање: Говорење на јазикот на моделот Дел 3 — Екстракција на карактеристики: Претворање на слики во значајни броеви Ненадгледувано групирање: Откривање структура во темнината Дел 5 – Полунадзорно обука: Основниот експеримент Дел 6 — Скалирање на милиони слики: Реалистична карта на патот Заклучок Етикетите се скапи, сликите се бесплатни Во совршен свет, секоја слика во вашиот сет на податоци би имала етикета. „Дефектен.“ „Нормален.“ „Крак тип А.“ „Крак тип Б.“ Но, во реалниот свет, етикетирањето е брутално скапо. Еден медицински радиолог може да означи можеби 50 скенирања на мозокот на час – за 200 евра на час. Инспекторот за индустриско квалитет може да анотира можеби 100 делови на час. Еве го парадоксот: компаниите често имаат милиони слики (од камери, сензори, кориснички поставувања), но можат да си дозволат да означат само мал дел. Наместо да ги фрлиме не-означените слики, ги користиме за да го подобриме моделот – комбинирање на мал означен сет со голем не-означен сет. semi-supervised learning Дозволете ми да користам аналогија. Замислете дека сте учител со 30 студенти. им давате на сите еден испит, но имате само време да ги оцените петте статии. Вие внимателно ги оцените тие пет и забележувате модели: студентите кои пишуваат многу имаат тенденција да добијат високи резултати, а студентите кои оставаат празни одговори имаат тенденција да добијат ниски резултати. Користејќи ги тие модели, можете да ги процените оценките на другите 25 статии без да ги прочитате сите редови. Тоа е полу-надгледувано учење: ги користите неколкуте оценети статии (податоци со етикети) за да ги разберете обрасците, а потоа ги примените тие обрасци на оние без етикети (неподатоци со етикети). Оваа статија гради комплетна полу-надгледувана цевка за класификација на слики од нула.Ние ќе работиме низ секој чекор со детални објаснувања како што одиме. Студија на случај: откривање на дефекти во производството на метални површини. Фабрика произведува челични плочи, а камерите фотографираат секоја плоча додека се одвива од производствената линија. Повеќето плочи се нормални, некои имаат дефекти (гребења, пукнатини, пити, инклузии). Имаме 10.000 слики, но само 200 етикетирани. Студија на случај: Фабрика произведува челични плочи, а камерите фотографираат секоја плоча како што се одвива од производствената линија.Повеќето плочи се нормални, некои имаат дефекти (гребења, пукнатини, пити, инклузии).Имаме 10.000 слики, но само 200 етикетирани. detecting manufacturing defects on metal surfaces. Изградба на полу-надгледувано учење цевка Пред да се нурне во било кој код, ајде да го нацртаме целиот гасовод. Разбирањето на протокот прво ќе направи секој чекор да се чувствува целен наместо случаен. Земете момент да го проучите овој дијаграм - секое поле е чекор што ќе го имплементираме: THE COMPLETE SEMI-SUPERVISED PIPELINE: [10,000 raw images] ──▶ [Exploration & Cleaning] │ ▼ [Preprocessing: resize, normalize, histogram equalization] │ ▼ [Feature Extraction: pretrained ResNet50 → 2048-dim embedding per image] │ ┌─────────┴──────────┐ │ │ ▼ ▼ [200 LABELED images] [9,800 UNLABELED images] │ │ │ ▼ │ [Clustering: K-Means, DBSCAN │ on embeddings → pseudo-labels] │ │ │ ▼ │ [WEAKLY labeled dataset] │ (cluster assignments) │ │ ▼ ▼ ┌────────────────────────────────────┐ │ SEMI-SUPERVISED TRAINING: │ │ 1. Pre-train CNN on weakly labeled │ │ 2. Fine-tune CNN on strongly labeled│ │ 3. Compare vs supervised-only │ └────────────────────────────────────┘ │ ▼ [Evaluation: F1, AUC-ROC, confusion matrix, comparison] Клучниот увид: ќе ги претвориме не-означените слики во Размислете за тоа како да му дадете на студентот груб водич за студирање пред вистинскиот испит - тоа нема да биде совршено, но тоа е подобро од ништо. приближно Ајде исто така да бидеме експлицитни за речникот што ќе го користиме во текот на овој напис, бидејќи мешањето на термините е многу чест извор на конфузија: Силно етикетирани (или само "етикетирани"): слики со етикети проверени од човечки експерт. Слабо етикетирани (или "псеудо-етикетирани"): слики чии етикети се претпоставени со кластерирање. Поевтини, но бучни. Неозначени: слики без етикети воопшто. Ова е нивната состојба пред да ги групираме. Вградување: компактен нумерички резиме на слика, произведена од страна на преттренирана невронска мрежа. Понатамошно читање : Понатамошно читање : Преглед на полу-надгледуваното учење - scikit-learn Google истражување за полу-надгледувано учење Преглед на полу-надгледуваното учење - scikit-learn Google истражување за полу-надгледувано учење Истражување на податоци: Разбирање на податоците со кои работите Зошто треба да ги погледнете вашите податоци пред да направите нешто друго Комплектите на податоци за слики имаат уникатни режими на неуспех што нема да ги најдете во табеларните податоци: оштетени датотеки кои се урнат во вашиот тренинг циклус во 3 часот наутро, неконзистентни резолуции кои тивко ги искривуваат вашите слики, погрешни канали за боја (сива скала маскирана како RGB) и екстремна нерамнотежа на класа каде што 95% од сликите се "нормални". Златното правило е: Ајде да видиме со што се занимаваме. never trust data you haven't inspected. 1.1 — Вчитање и броење на сликите Нашиот сет на податоци е организиран во две главни папки: една со означени слики (подредени на "нормални" и "дефектни" под-папки), и една со не-означени слики (без под-папки - само рамна колекција на на фајловите). .png import os import numpy as np import pandas as pd import matplotlib.pyplot as plt from PIL import Image from pathlib import Path data_dir = Path("data/metal_surfaces") labeled_dir = data_dir / "labeled" unlabeled_dir = data_dir / "unlabeled" Ние користиме наместо концитација на струни, бидејќи правилно ги обработува патечните сепаратори на кој било оперативен систем. pathlib.Path labeled_files = list(labeled_dir.glob("**/*.png")) unlabeled_files = list(unlabeled_dir.glob("**/*.png")) print(f"Labeled images: {len(labeled_files)}") print(f"Unlabeled images: {len(unlabeled_files)}") print(f"Total: {len(labeled_files) + len(unlabeled_files)}") label_ratio = len(labeled_files) / (len(labeled_files) + len(unlabeled_files)) print(f"Label ratio: {label_ratio:.1%}") Тоа коефициент на етикети е обично околу 2%. Само 2% од нашите податоци имаат етикети проверени од експерти. други 98% е златен рудник што не можеме да си дозволиме да го игнорираме - и тоа е токму она што полу-надзорното учење го искористува. 1.2 — Скенирање за проблеми: резолуција, боја, корупција Следно, треба да ја провериме секоја слика поединечно. Една поштедена датотека може да се урне во текот на целата обука. Неконзистентни резолуции ќе ги искриват сликите ако не сте внимателни. И погрешни бои начини (сива скала кога вашиот модел очекува RGB) ќе произведуваат глупости. Напишаме мала функција која се обидува да ја отвори секоја слика и да ги евидентира нејзините својства: def get_image_info(filepath): """ Try to open an image and record its properties. If the file is corrupted, Pillow will throw an exception. """ try: img = Image.open(filepath) return { "path": str(filepath), "width": img.size[0], "height": img.size[1], "mode": img.mode, # 'RGB', 'L' (grayscale), 'RGBA' "filesize_kb": os.path.getsize(filepath) / 1024, "corrupted": False, } except Exception: return { "path": str(filepath), "width": None, "height": None, "mode": None, "filesize_kb": None, "corrupted": True, } на Полето е особено важно. Значи 3 цветни канали (црвено, зелено и сино). значи сива скала (1 канал). значи RGB со алфа (транспарентност) канал. Нашиот пред-трениран модел очекува RGB, па ќе треба да конвертираме сè подоцна. img.mode 'RGB' 'L' 'RGBA' Сега да ги скенираме сите 10.000 слики: all_files = labeled_files + unlabeled_files image_info = [get_image_info(f) for f in all_files] info_df = pd.DataFrame(image_info) И да ги разгледаме резултатите: print(f"Total images scanned: {len(info_df)}") print(f"Corrupted images: {info_df['corrupted'].sum()}") print(f"\nResolution distribution:") print(info_df[~info_df["corrupted"]][["width", "height"]].describe().round(0)) print(f"\nColor modes: {info_df['mode'].value_counts().to_dict()}") print(f"File size (KB): min={info_df['filesize_kb'].min():.0f}, " f"max={info_df['filesize_kb'].max():.0f}, " f"mean={info_df['filesize_kb'].mean():.0f}") Што да барате во производот: Корумпирани слики > 0: Веднаш ги отстранете. Дури и една лоша датотека може да се урне вашата обука. Различни резолуции: Ако min ≠ max за ширина или висина, сликите имаат различни големини. Многу режими на боја: Ако видите и "RGB" и "L", ќе имате мешавина на боја и сива скала. Екстремни големини на датотеки: 1KB датотека е веројатно празна или оштетена. 50MB датотека може да биде некомпресирана – вреди да се испита. Да ги исчистиме оштетените датотеки: corrupted_paths = set(info_df[info_df["corrupted"]]["path"].tolist()) if corrupted_paths: print(f"Removing {len(corrupted_paths)} corrupted images") labeled_files = [f for f in labeled_files if str(f) not in corrupted_paths] unlabeled_files = [f for f in unlabeled_files if str(f) not in corrupted_paths] 1.3 — Класна дистрибуција: Дали нашиот етикетиран сет е балансиран? Во индустриски и медицински поставувања, дефектите се ретки. Вашиот означен сет може да биде 90% "нормален" и 10% "дефект". Ова е од огромна важност: мрзливиот модел кој секогаш предвидува "нормален" ќе добие 90% точност додека е целосно бескорисен. class_counts = {} for class_dir in labeled_dir.iterdir(): if class_dir.is_dir(): count = len(list(class_dir.glob("*.png"))) class_counts[class_dir.name] = count print("Class distribution (labeled set):") for cls, count in class_counts.items(): pct = count / sum(class_counts.values()) * 100 print(f" {cls}: {count} images ({pct:.1f}%)") Ако дисбалансот е тежок, подоцна ќе се справиме со него користејќи техника наречена во функцијата за губење - во суштина кажувајќи на моделот "пропуштањето на дефект е 4 пати полошо од лажен аларм". pos_weight Ајде да го визуализираме: fig, ax = plt.subplots(figsize=(6, 4)) ax.bar(class_counts.keys(), class_counts.values(), color=["#2ecc71", "#e74c3c"]) ax.set_title("Class Distribution (Labeled Images)") ax.set_ylabel("Number of images") plt.tight_layout() plt.savefig("outputs/class_distribution.png", dpi=150) plt.show() 1.4 — Визуализирање на слики од примероци: секогаш погледнете пред моделот Ова може да биде најважниот чекор во целиот гасовод. Погледнете ги вашите податоци. Може да откриете слики кои се очигледно погрешно означени, скенирање на артефакти (црни граници, ротации), проблеми со квалитетот (замрзнување, преекспозиција), или дека дефектите се визуелно суптилни и вашата задача е потешка отколку што мислевте. fig, axes = plt.subplots(2, 5, figsize=(15, 6)) fig.suptitle("Sample Images — Top: Normal | Bottom: Defect", fontsize=14) for i, class_name in enumerate(["normal", "defect"]): class_files = list((labeled_dir / class_name).glob("*.png"))[:5] for j, filepath in enumerate(class_files): img = Image.open(filepath) axes[i, j].imshow(img, cmap="gray" if img.mode == "L" else None) axes[i, j].set_title(class_name, fontsize=10) axes[i, j].axis("off") plt.tight_layout() plt.savefig("outputs/sample_images.png", dpi=150) plt.show() Земете момент за да ги проучите овие слики. Ако недостатоците се очигледни (длабока огреботина, голема пукнатина), тоа е охрабрувачко - моделот треба да може да ја научи разликата. Ако тие се суптилни (лека промена на бојата, пукнатина на линијата на косата), ќе ви треба особено добра преработка и екстракција на карактеристики. Ти Понатамошно читање : Понатамошно читање : Pillow документација – библиотеката за Python слики што ги користиме за вчитање на слики NEU Surface Defect Database – вистински светски сет на податоци за површински дефекти на челик што можете да ги практикувате со Пилинг документација Нова база на податоци за дефекти на површината Дел 2 — Пред-обработка: зборување на јазикот на моделот Зошто не можеме само да ги нахраниме суровите слики на невронската мрежа Пред-трениран CNN како ResNet50 беше обучен на многу специфичен тип на влез: 224×224 пиксел слики, во RGB боја, нормализирани со специфични просечни и стандардни вредности на отстапување пресметани од ImageNet сет на податоци. Размислете за тоа како за јазик. ResNet50 " зборува за ImageNet." Ако сакаме да ги разбереме нашите метални слики на површината, прво треба да ги "преведеме" во формат ImageNet. Конвертирајте во RGB (3 канала) Подобрување на контрастот преку хистографска еквилација Големината е 224×224 Нормализирајте ги вредностите на пикселите за да одговараат на статистика на ImageNet 2.1 - Што е хистографска изедначување, и зошто е важно тука? Разликата помеѓу нормална површина и огребена површина може да биде само неколку нивоа на интензитет на пиксели – невидливи за голо око, и многу тешко за модел да се открие. редистрибуира пиксел интензитети, така што целиот опсег (0 до 255) се користи еднакво.Резултатот: суптилни карактеристики "поп" визуелно и нумерички. Histogram equalization Ние користиме повеќе напредна верзија наречена За разлика од глобалната еквилализација (која ја применува истата трансформација на целата слика), CLAHE ја дели сликата на мали плочки (8×8 по претпоставка) и ги изедначува секоја плочка независно. CLAHE Глобалната еквилализација е како да користите еден слајдер за осветлување за целата слика – може да ги осветлите темните агли, но да го измиете веќе осветлениот центар. CLAHE е како да ја прилагодувате осветленоста во секој регион независно, така што секој дел од сликата станува јасен. 2.2 Изградба на сопствен PyTorch Dataset PyTorch организира полнење на податоци околу две класи: (знае како да се вчита еден елемент) и (знае како да парче и shuffle предмети). PyTorch обезбедува вграден сет на податоци, но се претпоставува дека секоја слика има етикета. Нашата сет на податоци има и етикетирани и не-етикетирани слики, па ни е потребна сопствена класа. Dataset DataLoader ImageFolder Ајде да го изградиме тоа чекор по чекор. Прво, скелетот: import torch import torchvision.transforms as T from torch.utils.data import Dataset, DataLoader import cv2 class MetalSurfaceDataset(Dataset): """ Custom Dataset that handles both labeled and unlabeled images. Returns -1 as the label for unlabeled images. """ def __init__(self, image_paths, labels=None, transform=None): self.image_paths = image_paths self.labels = labels # None for unlabeled images self.transform = transform def __len__(self): return len(self.image_paths) PyTorch кажува колку слики имаме. ги зачувува патеките на сликите и (опционално) нивните етикети. __len__ __init__ Сега основната метода, , која се вчита и пре-процесира една слика. Ние ќе го поделиме во три фази: __getitem__ Stage 1 — Load the image and force RGB: def __getitem__(self, idx): # Load image and convert to RGB # .convert("RGB") handles grayscale → RGB conversion automatically # (it duplicates the single channel into R, G, and B) img = Image.open(self.image_paths[idx]).convert("RGB") Зошто Затоа што ResNet50 очекува 3 канали. Ако нашата слика е сива скала (1 канал), ова ги дуплира сивите вредности во R, G и B. Ако веќе е RGB, тоа не прави ништо. .convert("RGB") Stage 2 — Apply CLAHE histogram equalization: # Convert PIL Image → numpy array for OpenCV processing img_np = np.array(img) # Convert RGB → LAB color space # L = Lightness (brightness), A and B = color channels # We only equalize L (brightness) to avoid distorting colors img_lab = cv2.cvtColor(img_np, cv2.COLOR_RGB2LAB) # Create CLAHE object and apply to L channel # clipLimit=2.0 prevents over-amplification of noise # tileGridSize=(8,8) means 8x8 tiles for local equalization clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) img_lab[:, :, 0] = clahe.apply(img_lab[:, :, 0]) # Convert back LAB → RGB → PIL Image img_np = cv2.cvtColor(img_lab, cv2.COLOR_LAB2RGB) img = Image.fromarray(img_np) Зошто LAB, а не само примената на CLAHE директно на RGB? Бидејќи ако ги изедначите R, G и B каналите независно, ќе ги искривите боите – претворајќи ги сините во зелени, на пример. Stage 3 — Apply transforms and return: # Apply resize + normalize transforms if self.transform: img = self.transform(img) # Return the label (or -1 if this image has no label) label = self.labels[idx] if self.labels is not None else -1 return img, label 2.3 — Трансформацискиот гасовод: што прави секој чекор Сега ја дефинираме секвенцата на трансформации.Секоја од нив има специфична цел: preprocessing = T.Compose([ T.Resize((224, 224)), # (1) Resize to model's expected input size T.ToTensor(), # (2) PIL Image → PyTorch Tensor, scale [0,1] T.Normalize( mean=[0.485, 0.456, 0.406], # (3) Normalize with ImageNet statistics std=[0.229, 0.224, 0.225], ), ]) Да ги објасниме сите чекори: Архитектурата на ResNet50 бара токму таква големина. Ако внесувате слика од 300×400, облиците на тензорите нема да се совпаднат и PyTorch ќе се распадне. (1) Resize to 224×224. Таа прави две работи: конвертира формат на пиксели од HWC (Висина × Ширина × Канали) во CHW (Канали × Висина × Ширина), што е она што го очекува PyTorch; и ги скалира вредностите на пикселите од [0, 255] цели до [0.0, 1.0] пловечки. (2) ToTensor. Овие магични броеви - и - е просечното и стандардното отстапување на вредностите на пикселите низ целиот сет на податоци на ImageNet, пресметан канал по канал (R, G, B). ResNet50 беше обучен со овие точни вредности за нормализација, така што нејзините внатрешни тежини "очекуваат" влезови центрирани околу 0 со единица варијанта. (3) Normalize with ImageNet mean and std. [0.485, 0.456, 0.406] [0.229, 0.224, 0.225] 2.4 Креирање на сетови на податоци и DataLoaders Сега ние ги собираме сите. Критичен принцип: Мешањето на нив би го загадило нашето оценување. labeled and unlabeled data must be kept strictly separate at all times. Прво, соберат означени слики патеки и нивните класи етикети: labeled_paths = [] labeled_labels = [] for class_idx, class_name in enumerate(["normal", "defect"]): class_dir = labeled_dir / class_name for fp in class_dir.glob("*.png"): labeled_paths.append(str(fp)) labeled_labels.append(class_idx) # 0 = normal, 1 = defect Потоа, соберат не-означени слики патеки (не е потребно ознаки): unlabeled_paths = [str(fp) for fp in unlabeled_files] Креирање на PyTorch Dataset објекти: labeled_dataset = MetalSurfaceDataset(labeled_paths, labeled_labels, preprocessing) unlabeled_dataset = MetalSurfaceDataset(unlabeled_paths, labels=None, transform=preprocessing) print(f"Labeled dataset: {len(labeled_dataset)} images") print(f"Unlabeled dataset: {len(unlabeled_dataset)} images") На крајот, свртете ги во DataLoaders.A DataLoader ги спојува сликите заедно (batch_size=32 значи 32 слики во исто време) и опционално ги шифрува: labeled_loader = DataLoader(labeled_dataset, batch_size=32, shuffle=False) unlabeled_loader = DataLoader(unlabeled_dataset, batch_size=32, shuffle=False) Зошто Бидејќи ние сме за да се извлече карактеристики, и ние треба вградувањата да останат во ист редослед како нашите списоци на датотеки. shuffle=False Понатамошно читање : Понатамошно читање : Креативните трансформации – комплетна листа CLAHE објасни (OpenCV туториал) PyTorch Dataset & DataLoader Упатство Креативните трансформации – комплетна листа CLAHE објасни (OpenCV туториал) PyTorch Dataset & DataLoader Упатство Дел 3 – Извлекување на карактеристики: претворање на слики во значајни броеви 3.1 – Зошто суровите пиксели се страшно претставување 224×224 RGB слика има 150,528 броеви (224 × 224 × 3 канали). Повеќето од нив се бучава - мали варијации во осветлување, сензорски артефакти, артефакти за компресија. Полошо: две фотографии од истиот нула, земени од малку различни агли или осветлување, имаат сосема различни вредности на пиксели. Ако се обидеме да ги групираме или класифицираме суровите пиксели, сликите кои изгледаат исто за нас ќе изгледаат сосема поинаку за алгоритам. Она што ни е потребно е претставување кое го фаќа на сликата - "ова изгледа како надразнување", "ова е мазна површина" - во компактна, стабилна нумеричка форма. да се. meaning embeddings 3.2 - Што е пред-трениран модел и зошто не се обучуваме од нула Обуката на длабока невронска мрежа од нула бара многу податоци – обично стотици илјади слики. Имаме 200 означени слики. Ако се обидеме да ги обучиме 25 милиони параметри на ResNet50 на 200 слики, моделот совршено ќе се запамети секоја слика за обука, но целосно ќе пропадне на нови слики. . overfitting Наместо тоа, ние користиме модел кој веќе беше обучен на ImageNet – сет на податоци од 14 милиони слики во 1.000 категории (кучиња, мачки, автомобили, згради, итн.). - тие се однесуваат на челични површини исто како што се однесуваат на мачки. universal Размислете за тоа како да ангажирате искусен фотограф за да ја инспектирате вашата фабрика. : тие можат да забележат необични текстури, ненадејни промени во квалитетот на површината, обрасци кои ја кршат нормата. Види 3.3 — Превземање ResNet50 Ајде да го вчитаме пред-тренираниот модел: import torchvision.models as models resnet = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) Овој еден ред симнува (по прв пат) модел ResNet50 чии тегови се обучени на ImageNet. 3.4 – Замрзнување на параметрите Ние не сакаме да ги модифицираме ниту една од научените карактеристики. Ние користиме ResNet како алатка за само читање: for param in resnet.parameters(): param.requires_grad = False Ова има две предности: го спречува случајно модификација на преттренираните тегови, и го прави заклучокот побрз (без градиентно следење = помалку пресметка и помалку меморија). requires_grad = False 3.5 – Отстранување на насловот за класификација Архитектурата на ResNet50 изгледа вака: Input image (224×224×3) ↓ [Convolutional layers] — learn visual features ↓ [Average Pooling] — compress spatial dimensions → 2048-dim vector ↓ [Fully Connected layer] — classify into 1000 ImageNet categories ↓ Output (1000 probabilities) Ние не сакаме последниот целосно поврзан слој, бидејќи е специфичен за 1000-те класи на ImageNet (куче, мачка, авион...) и бескорисен за нашата задача. feature_extractor = torch.nn.Sequential(*list(resnet.children())[:-1]) Што прави оваа линија: ги враќа сите слоеви на ResNet како листа. ги зема сите слоеви освен последниот (FC слој). Завртете ги назад во модел. resnet.children() [:-1] Sequential(*) feature_extractor.eval() режимот го оневозможува откажувањето и ги користи статистиките за работа за нормализација на партиите. излез – истата слика секогаш произведува иста вградување. eval() deterministic device = torch.device("cuda" if torch.cuda.is_available() else "cpu") feature_extractor = feature_extractor.to(device) print(f"Feature extractor ready on {device}") Користењето на GPU (ако е достапно) го прави екстракцијата на карактеристики приближно 10 пати побрза. 3.6 - Функцијата на екстракција Сега, ајде да напишеме функција која ги храни партиите на слики преку екстракторот на карактеристики и ги собира вградувањата. Надворешната структура : def extract_embeddings(dataloader, model, device): """ Feed all images through the model and collect embeddings. Returns: embeddings: numpy array, shape (n_images, 2048) labels: numpy array, shape (n_images,) — -1 if unlabeled """ all_embeddings = [] all_labels = [] Ние акумулираме резултати во списоци затоа што ги обработуваме сликите во серии (32 во исто време), а не сите одеднаш (не би се вклопиле во меморијата на GPU). Главниот круг е: with torch.no_grad(): for batch_images, batch_labels in dataloader: batch_images = batch_images.to(device) features = model(batch_images) го оневозможува следењето на градиентот – неопходно е затоа што ние само правиме заклучоци, а не обука. ги преместува сликите на GPU ако имаме еден. torch.no_grad() batch_images.to(device) Извозот на Имаат форма Последните две димензии се просторни остатоци од просечното здружување. model(batch_images) (batch_size, 2048, 1, 1) features = features.squeeze(-1).squeeze(-1) # Now shape is (batch_size, 2048) — that's our embedding Конечно, ги преместуваме резултатите назад во процесорот (numpy не може да работи со GPU тензорите) и да ги зачуваме: all_embeddings.append(features.cpu().numpy()) all_labels.append(batch_labels.numpy()) return np.concatenate(all_embeddings), np.concatenate(all_labels) Завртете ги сите парчиња заедно во една матрица. np.concatenate 3.7 – Извршување на екстракцијата print("Extracting embeddings for labeled images...") labeled_embeddings, labeled_labels_arr = extract_embeddings( labeled_loader, feature_extractor, device ) print(f" Shape: {labeled_embeddings.shape}") # Expected: (200, 2048) 200 слики, секоја претставена со 2048-димензионален вектор. Тоа е 73 пати компресија во споредба со сурови пиксели (150,528 → 2 048) – и компресираното претставување е Поважно од тоа. Долго print("Extracting embeddings for unlabeled images...") unlabeled_embeddings, _ = extract_embeddings( unlabeled_loader, feature_extractor, device ) print(f" Shape: {unlabeled_embeddings.shape}") # Expected: (9800, 2048) Ние ги отфрламе етикетите (те се сите -1 во секој случај) со Варијација на. _ 3.8 - Заштеда на вградувања (не повторно извлекување секој пат!) Екстракцијата на карактеристики е најскапиот чекор – потенцијално 30+ минути на GPU за 10.000 слики. np.save("data/labeled_embeddings.npy", labeled_embeddings) np.save("data/labeled_labels.npy", labeled_labels_arr) np.save("data/unlabeled_embeddings.npy", unlabeled_embeddings) print("Embeddings saved to disk") Подоцна, можете веднаш да го преземете . np.load("data/labeled_embeddings.npy") 3.9 – Проверка на здравјето: дали вградувањата се разумни? Пред да продолжиме, брза проверка. ѓубре во, ѓубре надвор - ајде да бидеме сигурни дека нашите вградувања се здрави: print(f"Embedding statistics:") print(f" Mean: {labeled_embeddings.mean():.4f}") print(f" Std: {labeled_embeddings.std():.4f}") print(f" Min: {labeled_embeddings.min():.4f}") print(f" Max: {labeled_embeddings.max():.4f}") print(f" NaN: {np.isnan(labeled_embeddings).any()}") print(f" Inf: {np.isinf(labeled_embeddings).any()}") Што да очекувате: значи околу 0.3-0.5, std околу 0.5-1.0, без NaN, без Inf. Ако ги видите вредностите на NaN, повредената слика веројатно се лизнела низ чекорот на чистење. Да одиме понатаму: Да одиме понатаму: Трансфер учење објаснето (PyTorch туториал) ResNet хартија (He et al., 2015) — оригиналната архитектура која воведе прескок поврзувања Функција екстракција против фино прилагодување — Стенфорд CS231n курсеви белешки Трансфер учење објаснето (PyTorch туториал) Ресет хартија (He et al., 2015) Функционална екстракција против фино прилагодување Дел 4 – Ненадгледувано групирање: откривање на структурата во темнината 4.1 – Што прави кластерирањето и зошто ни е потребно Сега имаме 10.000 вградувања – компактни нумерички резимеа на секоја слика. 200 од нив имаат етикети. Другите 9.800 не. во податоците кои се надеваме дека одговараат на "нормално" и "неисправно". groupings Основната претпоставка: ако нашите вградувања се добри (и ResNet50 вградувања обично се), слики од ист тип ќе бидат Нормалните површини ќе се групираат заедно; дефектните површини ќе се групираат заедно. Блиско Но, прво, практичен проблем: 2048 димензии е невозможно да се визуелизираат и да се направат некои алгоритми бавни. 4.2 Стандардизирање на вградувањата Алгоритмите за групирање, особено K-Means, ги пресметуваат растојанијата помеѓу точките.Ако една димензија се движи од 0 до 1000, а друга од 0 до 0,01, првата ќе ја доминира растојанието – како да втората димензија не постои. from sklearn.preprocessing import StandardScaler # Combine labeled + unlabeled for joint standardization all_embeddings = np.concatenate([labeled_embeddings, unlabeled_embeddings], axis=0) scaler = StandardScaler() all_embeddings_scaled = scaler.fit_transform(all_embeddings) Зошто стандардизирајте етикетирани и не-етикетирани заедно? Бидејќи тие доаѓаат од иста дистрибуција (иста фабрика, иста камера). # Split back — we'll need them separate later labeled_scaled = all_embeddings_scaled[:len(labeled_embeddings)] unlabeled_scaled = all_embeddings_scaled[len(labeled_embeddings):] print(f"After standardization: mean={all_embeddings_scaled.mean():.4f}, " f"std={all_embeddings_scaled.std():.4f}") 4.3 — Намалување на димензиите со PCA PCA (Principal Component Analysis) ги наоѓа насоките на максимална варијанта во податоците и ги проектира во горните насоки. from sklearn.decomposition import PCA pca = PCA(n_components=50, random_state=42) all_pca = pca.fit_transform(all_embeddings_scaled) print(f"PCA: 2048 → 50 dimensions") print(f"Variance retained: {pca.explained_variance_ratio_.sum():.1%}") ~95% варијанта задржан значи дека сме го отфрлиле само 5% од информациите, но намалена димензионалност за 40x. Ајде исто така да визуелизираме колку секоја компонента придонесува: plt.figure(figsize=(10, 4)) plt.plot(np.cumsum(pca.explained_variance_ratio_), marker="o", markersize=3) plt.xlabel("Number of PCA components") plt.ylabel("Cumulative explained variance") plt.title("PCA: How many components do we need?") plt.axhline(y=0.95, color="r", linestyle="--", label="95% threshold") plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.savefig("outputs/pca_variance.png", dpi=150) plt.show() Оваа "заплетка на лакот" покажува каде додавањето на повеќе компоненти престанува да обезбедува значителни придобивки. 4.4 — Визуализирајте со t-SNE t-SNE е техника за нелинеарно намалување на димензионалноста која е специјално дизајнирана за визуелизација. : слики кои се блиски во високо-димензионален простор ќе бидат блиски во 2D заговор. Ова го прави совршен за проверка дали нормални и дефектни слики природно се одвојуваат. local structure Едно важно предупредување: t-SNE ги искривува глобалните растојанија – просторот помеѓу кластерите не е значаен.Користете го само за визуелизација и кластери на оригиналните (или PCA-редуцирани) вградувања. never cluster on t-SNE output. from sklearn.manifold import TSNE # Apply t-SNE on PCA output (faster and more stable than on raw 2048-dim) tsne = TSNE(n_components=2, random_state=42, perplexity=30, n_iter=1000) all_tsne = tsne.fit_transform(all_pca) на параметар приближно го контролира "големината на соседството" - колку блиски точки t-SNE смета. 30 е разумно претпоставката за сетови на податоци од нашата големина. perplexity Сега да ги поделиме t-SNE координатите: labeled_tsne = all_tsne[:len(labeled_embeddings)] unlabeled_tsne = all_tsne[len(labeled_embeddings):] За да визуелизирате: fig, axes = plt.subplots(1, 2, figsize=(14, 6)) # Left plot: labeled images only, colored by true label for cls_idx, cls_name, color in [(0, "Normal", "#2ecc71"), (1, "Defect", "#e74c3c")]: mask = labeled_labels_arr == cls_idx axes[0].scatter(labeled_tsne[mask, 0], labeled_tsne[mask, 1], label=cls_name, alpha=0.7, s=40, c=color) axes[0].set_title("t-SNE: Labeled Images (True Labels)") axes[0].legend() Ако видите два различни облаци во оваа парцела - зелена на едната страна, црвена на другата - тоа е одличен знак. # Right plot: all images (unlabeled in gray, labeled overlaid) axes[1].scatter(unlabeled_tsne[:, 0], unlabeled_tsne[:, 1], c="lightgray", alpha=0.2, s=10, label="Unlabeled") for cls_idx, cls_name, color in [(0, "Normal", "#2ecc71"), (1, "Defect", "#e74c3c")]: mask = labeled_labels_arr == cls_idx axes[1].scatter(labeled_tsne[mask, 0], labeled_tsne[mask, 1], label=f"Labeled: {cls_name}", alpha=0.8, s=40, c=color) axes[1].set_title("t-SNE: All Images (Labeled in Color)") axes[1].legend() plt.tight_layout() plt.savefig("outputs/tsne_visualization.png", dpi=150) plt.show() Во десната заговор, сивиот облак (не-означени слики) треба да се преклопува со обоените точки. Ова потврдува дека означени и не-означени слики доаѓаат од истата дистрибуција - неопходен услов за полу-надгледувано учење да работи. 4.5 К-мејс кластерирање K-Means е наједноставен и најшироко користен алгоритам за кластерирање, кој ги дели податоците во точно k-групи со итеративно доделување на секоја точка до најблискиот центар на кластерот, а потоа ги ажурира центрите. Бидејќи знаеме дека имаме 2 класи (нормални и дефектни), почнуваме со k = 2, но исто така тестираме k = 3, 4, 5 за да провериме дали податоците може да имаат повеќе структура (на пример, различни). од дефекти кои формираат одделни кластери). Типови За да процениме колку добро кластерите одговараат на вистинските етикети, користиме ARI = 1.0 значи совршена согласност со вистинските етикети. ARI = 0.0 значи случајно кластерирање. ARI < 0 значи полошо од случајно. ARI (Adjusted Rand Index) from sklearn.cluster import KMeans from sklearn.metrics import adjusted_rand_score, silhouette_score print("K-Means Clustering:") print(f" {'k':<5s} {'ARI':>8s} {'Silhouette':>12s}") print(f" {'-'*27}") for k in [2, 3, 4, 5]: kmeans = KMeans(n_clusters=k, random_state=42, n_init=10) all_clusters = kmeans.fit_predict(all_embeddings_scaled) # ARI: compare clusters vs true labels (on labeled images only) labeled_clusters = all_clusters[:len(labeled_embeddings)] ari = adjusted_rand_score(labeled_labels_arr, labeled_clusters) # Silhouette: internal quality measure (no labels needed) # How well-separated are the clusters? Range: -1 to +1 sil = silhouette_score(all_embeddings_scaled, all_clusters) print(f" {k:<5d} {ari:>8.4f} {sil:>12.4f}") на параметар значи K-Means ќе се кандидира 10 пати со различни случајни иницијализации и ќе го задржи најдобриот резултат. n_init=10 Ако k=2 дава највисок ARI, тоа потврдува дека нашите податоци имаат две природни групи кои се усогласуваат со нормалниот против дефектот. DBSCAN кластерирање (алтернативен пристап) DBSCAN работи многу поинаку од K-Means. Наместо да го наведете бројот на кластери, ќе наведете два параметри: eps (epsilon): максималното растојание помеѓу две точки за да се сметаат за соседи. min_samples: минималниот број на точки потребни за да се формира густа област (клустер). Размислете за тоа како "како преполн соседството треба да биде да се брои како кластер?" DBSCAN автоматски го одредува бројот на кластери И ги идентификува надворешните точки (точки кои не припаѓаат на ниту еден кластер - означени како -1). from sklearn.cluster import DBSCAN print("\nDBSCAN Clustering:") print(f" {'eps':<6s} {'min_s':<7s} {'clusters':>9s} {'noise':>7s} {'ARI':>8s}") print(f" {'-'*40}") Треба да тестираме повеќе комбинации на параметри, бидејќи "правилните" вредности зависат од податоците: for eps in [3.0, 5.0, 7.0, 10.0]: for min_samples in [5, 10, 20]: dbscan = DBSCAN(eps=eps, min_samples=min_samples) db_clusters = dbscan.fit_predict(all_pca) # Use PCA-reduced data n_clusters = len(set(db_clusters)) - (1 if -1 in db_clusters else 0) n_noise = (db_clusters == -1).sum() if n_clusters >= 2: labeled_db = db_clusters[:len(labeled_embeddings)] mask = labeled_db != -1 # Exclude noise points from ARI if mask.sum() > 10: ari = adjusted_rand_score(labeled_labels_arr[mask], labeled_db[mask]) print(f" {eps:<6.1f} {min_samples:<7d} {n_clusters:>9d} " f"{n_noise:>7d} {ari:>8.4f}") Забележете дека користиме PCA-редуцирани податоци ( DBSCAN се бори во многу високи димензии, бидејќи сите растојанија стануваат слични (проклетството на димензионалноста). all_pca Споредете го најдобриот ARI од DBSCAN со најдобриот од K-Means и изберете го победникот. 4.7 — Визуализирање на кластерите на заплетот t-SNE Ајде да видиме како изгледа најдоброто кластерирање на нашата t-SNE визуелизација: best_kmeans = KMeans(n_clusters=2, random_state=42, n_init=10) all_cluster_ids = best_kmeans.fit_predict(all_embeddings_scaled) fig, ax = plt.subplots(figsize=(8, 6)) scatter = ax.scatter(all_tsne[:, 0], all_tsne[:, 1], c=all_cluster_ids, cmap="coolwarm", alpha=0.4, s=15) ax.set_title("K-Means Clusters (k=2) on t-SNE") plt.colorbar(scatter, label="Cluster ID") plt.tight_layout() plt.savefig("outputs/kmeans_clusters_tsne.png", dpi=150) plt.show() Ако двете бои во овој заговор приближно одговараат на двете групи што ги видовте во означениот t-SNE заговор, кластерирањето работи. 4.8 – Доделување на псевдо-ознаки на не-означени слики Сега критичниот чекор: ги земаме доделувањата на кластерите и ги третираме како "слаби етикети" за не-означени слики. Но постои суптилност - K-Means произволно доделува идентификатори на кластери. Кластерот 0 може да одговара на "дефект" или "нормално". Извлечете ги псевдо-етикетите: unlabeled_pseudo_labels = all_cluster_ids[len(labeled_embeddings):] Проверете го усогласувањето со вистинските етикети: labeled_cluster_ids = all_cluster_ids[:len(labeled_embeddings)] # What fraction of labeled images in cluster 0 are actually "normal"? cluster_0_normal_rate = (labeled_labels_arr[labeled_cluster_ids == 0] == 0).mean() cluster_1_normal_rate = (labeled_labels_arr[labeled_cluster_ids == 1] == 0).mean() print(f"Cluster 0: {cluster_0_normal_rate:.1%} of labeled images are 'normal'") print(f"Cluster 1: {cluster_1_normal_rate:.1%} of labeled images are 'normal'") Ако кластерот 0 е главно дефекти (normal_rate < 50%), ние се сврти на мапирање: if cluster_0_normal_rate < 0.5: unlabeled_pseudo_labels = 1 - unlabeled_pseudo_labels print("Cluster IDs flipped to match convention (0=normal, 1=defect)") Ајде да ја погледнеме дистрибуцијата: print(f"\nPseudo-label distribution:") print(f" Normal (0): {(unlabeled_pseudo_labels == 0).sum()} images") print(f" Defect (1): {(unlabeled_pseudo_labels == 1).sum()} images") Сега имаме два одделни збирки на податоци со многу различни карактеристики: Силно етикетирани — 200 слики со вистински експертски етикети. Високо квалитетно, мала количина. Слабо етикетирани – 9.800 слики со псевдо-етикети базирани на кластери. Понизок квалитет (некои етикети се погрешни), но огромна количина. Златното правило е: Тие служат за различни цели во следниот чекор. never mix these two. Понатамошно читање : Понатамошно читање : K-Means објаснува (scikit-learn) DBSCAN објаснува (scikit-learn) Како правилно да го прочитате t-SNE (Distill) — основно читање K-Means објаснува (scikit-learn) DBSCAN објаснува (scikit-learn) Како правилно да го прочитате t-SNE (Distill) Дел 5 – Полунадзорно обука: вистинскиот експеримент 5.1 – Логиката зад нашиот пристап во две фази Замислете дека обучувате нов инспектор за квалитет во фабриката: : Им покажуваш 9.800 фотографии и им кажуваш: овие се нормални и овие се дефектни, но не сум 100% сигурен.“ Инспекторот почнува да формира груб ментален модел. Некои од етикетите се погрешни, но општата шема – нормалните површини се мазна и еднаква, дефектните површини имаат неправилности – е главно точна. . Phase 1 (pre-training on pseudo-labels) Мислете Интуицијата Потоа им покажувате 200 фотографии кои внимателно ги проверил експерт: „Овие се дефинитивно нормални, а овие дефинитивно се дефектни.“ Инспекторот го рафинира нивниот ментален модел – ги коригира грешките од фаза 1 и го остри нивното судење за случаите на раб. Phase 2 (fine-tuning on real labels) Резултатот: еден инспектор кој има 10.000 слики (изградба на широка интуиција) и е Очекуваме овој инспектор да го надмине оној кој само ги видел 200 проверени примероци. Види калибрација За да го докажеме ова, ние вршиме два паралелни експерименти: Експеримент А – само под надзор: обука на 200 означени слики само Експеримент Б – полу-надгледуван: пред-тренинг на 9.800 псевдо-означени слики, а потоа фин тон на 200 означени слики Иста архитектура на моделот, ист тест сет. Единствената разлика е дали моделот ги гледа податоците без етикети или не. 5.2 — Изградба на класификаторот: архитектура Ние повторно го користиме ResNet50 како 'рбетот', но овој пат ние финалниот слој со бинарниот класификатор и ние тоа (за разлика од Дел 3, каде што ние само извлече карактеристики). replace train import torch.nn as nn class DefectClassifier(nn.Module): """ Binary classifier: Normal (0) vs Defect (1). Based on ResNet50 with a custom classification head. """ def __init__(self, dropout_rate=0.5): super().__init__() self.backbone = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) num_features = self.backbone.fc.in_features # 2048 Тука го заменуваме оригиналниот наслов за класификација на ImageNet (2048 → 1000 класи) со нашиот сопствен: self.backbone.fc = nn.Sequential( nn.Dropout(p=dropout_rate), # Anti-overfitting nn.Linear(num_features, 1), # Binary output ) def forward(self, x): return self.backbone(x) Зошто Со само 200 етикетирани слики и 25 милиони параметри, преоптоварувањето е главната закана. Dropout случајно оневозможува 50% од невроните за време на секој тренинг чекор, присилувајќи ја мрежата да научи излишни претставувања. Во времето на заклучување, сите неврони се активни. Dropout(0.5) Зошто За бинарната класификација, еден излезен неврон со сигмоидна активација е математички еквивалентен на два неврона со софтвермакс, но поедноставен и малку повеќе нумерички стабилен. Linear(2048, 1) 5.3 - Функција на губење: ракување со дисбаланс на класа Пред да го напишете тренинг лакот, ајде да разговараме за функцијата за губење. (Бинарна крос-ентропија со Logits), која ги комбинира сигмоидната активација со бинарната крос-ентропија во една, нумерички стабилна операција. BCEWithLogitsLoss Клучниот додаток е : pos_weight pos_weight = torch.tensor([4.0]).to(device) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) Што прави Тоа ја кажува функцијата за губење: "изгубениот дефект (лажен негативен) треба да се казни Тоа го компензира дисбалансот на класата.Без него, моделот би можел да постигне 80% точност со секогаш предвидување на "нормално" - што е бескорисно. pos_weight=4.0 4 times more Вредноста 4.0 е груба проценка врз основа на односот на класата. Ако имате 80% нормален / 20% дефект, тогаш Можете да ја прилагодите оваа вредност, но 4.0 е добра почетна точка. pos_weight = 80/20 = 4.0 5.4 — Оптимизатор: AdamW со деградација на тежината import torch.optim as optim optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) Зошто AdamW? Тоа е Адам со соодветна деградација на тежината (L2 регулација). нежно ги казнува големите тегови, што е уште еден слој на заштита од преоптоварување. Размислете за тоа како да му кажете на моделот "предпочитаат поедноставни објаснувања". weight_decay=1e-4 5.5 - Распоред на учење стапка scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=3, factor=0.5) Ова автоматски ја намалува стапката на учење кога загубата на валидација престанува да се подобрува. Тоа значи „чекај три епохи без подобрување пред да се намали“. Ова е од суштинско значење за конвергенцијата – како што моделот се приближува до минимум, помалите чекори го спречуваат преоптоварувањето. patience=3 factor=0.5 5.6 — Курсот на обука: една епоха во исто време Сега да ја изградиме целосната функција за обука.Ќе одиме низ секој дел од кругот одделно. (една епоха - еден премин низ сите податоци за обука): The training phase from sklearn.metrics import f1_score def train_model(model, train_loader, val_loader, epochs, lr, device, phase_name=""): """Train the model and track validation F1 score.""" pos_weight = torch.tensor([4.0]).to(device) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=3, factor=0.5) best_f1 = 0 for epoch in range(epochs): # ---- TRAINING ---- model.train() # Enable dropout, update batch norm stats for images, labels in train_loader: images = images.to(device) labels = labels.float().unsqueeze(1).to(device) # .float() because BCEWithLogitsLoss expects float targets # .unsqueeze(1) adds a dimension: shape (batch,) → (batch, 1) optimizer.zero_grad() # Reset gradients from previous batch outputs = model(images) # Forward pass loss = criterion(outputs, labels) # Compute loss loss.backward() # Compute gradients (backpropagation) optimizer.step() # Update weights Секоја партија поминува низ класичниот циклус: напредниот премин → пресметка на загубата → градиентите на заднината → тежините за ажурирање → градиентите за ресетирање за следната партија. (после секој период на обука): The validation phase # ---- VALIDATION ---- model.eval() # Disable dropout, use fixed batch norm stats all_preds, all_true = [], [] val_loss_total = 0 with torch.no_grad(): # No gradients needed for evaluation for images, labels in val_loader: images = images.to(device) outputs = model(images) # Compute validation loss val_loss_total += criterion( outputs, labels.float().unsqueeze(1).to(device) ).item() # Convert raw logits → binary predictions # sigmoid maps logits to [0, 1], then threshold at 0.5 preds = (torch.sigmoid(outputs) >= 0.5).int().cpu().numpy().flatten() all_preds.extend(preds) all_true.extend(labels.numpy()) Забележете го Ова е важно: тоа го оневозможува отпуштањето (сите неврони се активни) и ги користи статистиките за работа за нормализација на партиите наместо статистика за партиите. model.eval() Track metrics and update scheduler: val_f1 = f1_score(all_true, all_preds, average="binary") scheduler.step(val_loss_total) # Reduce LR if loss plateaued if val_f1 > best_f1: best_f1 = val_f1 if (epoch + 1) % 5 == 0: print(f" [{phase_name}] Epoch {epoch+1}/{epochs}: val_f1={val_f1:.4f}") print(f" [{phase_name}] Best F1: {best_f1:.4f}") return best_f1 Ние го следиме Формула 1 во сите епохи, а не само во последната.Моделите често достигнуваат врв пред крајот на обуката (потоа може да се надминат малку). best 5.7 – Подготовка на поделбите на податоците Ние ги поделивме означените податоци во воз (70%) и тест (30%). : тоа никогаш не се користи за обука во било кој експеримент. стратификација обезбедува сооднос на класа се зачува во двата дела. sacred from sklearn.model_selection import train_test_split labeled_train_idx, labeled_test_idx = train_test_split( range(len(labeled_paths)), test_size=0.3, random_state=42, stratify=labeled_labels, ) Креирајте ги трите податоци што ни се потребни: # 1. Labeled training data (for supervised training + fine-tuning) train_labeled_ds = MetalSurfaceDataset( [labeled_paths[i] for i in labeled_train_idx], [labeled_labels[i] for i in labeled_train_idx], preprocessing, ) # 2. Test data (for evaluation only — NEVER used for training) test_ds = MetalSurfaceDataset( [labeled_paths[i] for i in labeled_test_idx], [labeled_labels[i] for i in labeled_test_idx], preprocessing, ) # 3. Weakly labeled data (pseudo-labels from clustering) weakly_labeled_ds = MetalSurfaceDataset( unlabeled_paths, unlabeled_pseudo_labels.tolist(), preprocessing, ) И на податоците: train_labeled_loader = DataLoader(train_labeled_ds, batch_size=16, shuffle=True) test_loader = DataLoader(test_ds, batch_size=16, shuffle=False) weakly_labeled_loader = DataLoader(weakly_labeled_ds, batch_size=32, shuffle=True) print(f"Train (labeled): {len(train_labeled_ds)} images") print(f"Test: {len(test_ds)} images") print(f"Weakly labeled: {len(weakly_labeled_ds)} images") Забележете ги различните големини на партиите: 16 за малиот етикетиран сет (помалку слики по епоха), 32 за големиот слабо етикетиран сет (побрза обработка). за обука за да се спречи моделот да го научи редоследот на примероци. shuffle=True 5.8 – Експеримент А: само под надзор (база) Ова е поедноставен експеримент. Ние обучуваме нов модел користејќи само 140 означени тренинг слики (останатите 60 се резервирани за тестирање). print("=" * 60) print("EXPERIMENT A: SUPERVISED ONLY (140 labeled images)") print("=" * 60) model_supervised = DefectClassifier(dropout_rate=0.5).to(device) f1_supervised = train_model( model_supervised, train_labeled_loader, test_loader, epochs=30, lr=1e-4, device=device, phase_name="Supervised" ) 5.9 – Експеримент Б: полу-надгледуван (пристапот во две фази) Фаза 1 му дава на моделот широка интуиција од 9.800 псевдо-означени слики. Phase 1 — Pre-training on weakly labeled data: print("\n" + "=" * 60) print("EXPERIMENT B: SEMI-SUPERVISED") print("=" * 60) model_semi = DefectClassifier(dropout_rate=0.5).to(device) print("\nPhase 1: Pre-train on pseudo-labeled data (9,800 images)...") train_model( model_semi, weakly_labeled_loader, test_loader, epochs=10, lr=1e-4, device=device, phase_name="Pre-train" ) Ние тренираме само за 10 епохи тука, бидејќи псевдо-етикетите се бучни. Phase 2 — Fine-tuning on strongly labeled data: print("\nPhase 2: Fine-tune on real labeled data (140 images)...") f1_semi = train_model( model_semi, train_labeled_loader, test_loader, epochs=20, lr=5e-5, device=device, phase_name="Fine-tune" ) Забележете го (5e-5 vs 1e-4 во фаза 1). Ова е намерно и критично. Ако користиме висока стапка на учење за време на финото прилагодување, моделот брзо ќе "заборави" на сè што научи за време на пред-тренинг - градиентите би биле преголеми и би ги пренапишале претходно обучените тегови. Нежна стапка на учење му овозможува на моделот да направи мали корекции на своето постоечко знаење, одржувајќи ги широките модели од фаза 1, додека ги поправува грешките со вистински етикети. lower learning rate Ова е аналогно на фабричкиот инспектор: не ја започнувате нивната обука од нула во фаза 2. 5.10 — Конечна проценка: Моментот на вистината Сега ги оценуваме двата модели на истиот тест сет со повеќе метрики. Прво, функцијата на оценување: from sklearn.metrics import roc_auc_score, classification_report def full_evaluation(model, test_loader, device, name): """ Evaluate on the test set. Returns F1 score and AUC-ROC. """ model.eval() all_preds, all_probs, all_true = [], [], [] with torch.no_grad(): for images, labels in test_loader: outputs = model(images.to(device)) probs = torch.sigmoid(outputs).cpu().numpy().flatten() all_probs.extend(probs) all_preds.extend((probs >= 0.5).astype(int)) all_true.extend(labels.numpy()) И двете ги собираме (за AUC-ROC, кој го мери квалитетот на рангирање) и (за F1, кој го мери квалитетот на класификација на прагот од 0,5): probabilities binary predictions f1 = f1_score(all_true, all_preds, average="binary") auc = roc_auc_score(all_true, all_probs) print(f"\n{name}:") print(f" F1 Score: {f1:.4f}") print(f" AUC-ROC: {auc:.4f}") print(classification_report( all_true, all_preds, target_names=["Normal", "Defect"] )) return f1, auc Зошто Моделот кој секогаш предвидува „нормално“ добива 80% точност, но 0% потсетување на дефекти. F1 е хармонична средина на прецизност и потсетување – казнува модели кои ја игнорираат малцинската класа. F1 Зошто Таа мери колку добро моделот слики (дефектните слики треба да добијат повисоки веројатности од нормалните слики), без оглед на прагот на класификација. AUC од 1.0 значи совршен ранг; 0.5 значи случаен. AUC-ROC рангирање Сега за споредбата: f1_sup, auc_sup = full_evaluation( model_supervised, test_loader, device, "SUPERVISED ONLY" ) f1_semi, auc_semi = full_evaluation( model_semi, test_loader, device, "SEMI-SUPERVISED" ) И конечната пресуда: print("=" * 60) print("FINAL COMPARISON") print("=" * 60) print(f" {'Metric':<12s} {'Supervised':>12s} {'Semi-supervised':>16s} {'Delta':>8s}") print(f" {'-'*50}") print(f" {'F1':<12s} {f1_sup:>12.4f} {f1_semi:>16.4f} {f1_semi - f1_sup:>+8.4f}") print(f" {'AUC-ROC':<12s} {auc_sup:>12.4f} {auc_semi:>16.4f} {auc_semi - auc_sup:>+8.4f}") Ако делта колоната покажува позитивни броеви, докажавме дека податоците без етикети се корисни.Псевдо-етикетите, и покрај тоа што се несовршени, му дадоа на моделот почеток на главата што чистиот надзор на 200 слики не можеше да се совпадне. 5.11 – Интерпретирање на резултатите Еве како да го прочитате споредбата: F1 подобрена за +0.05 или повеќе: јасна победа за полу-надгледувани. F1 подобрена од +0.01 до +0.04: скромно подобрување. полу-надгледува помага, но маргината е мала. F1 непроменета или полоша: псевдо-етикетите беа премногу бучни за да помогнат, или кластерот не ја фати вистинската структура. Понатамошно читање : Понатамошно читање : Псевдо-етикетирање хартија (Ли, 2013) — оригиналниот пристап Полунабљудувано истражување за учење (van Engelen & Hoos) – сеопфатен преглед на методите Најдобри практики за обука на PyTorch Папир за псевдо-етикетирање (Ли, 2013) Полу-надгледувано истражување за учење (van Engelen & Hoos) Најдобри практики за обука на PyTorch Дел 6 — Скалирање на милиони слики: реалистична патна карта Прашањето од бизнисот „Вашиот доказ за концептот работи на 10.000 слики. Имаме 4 милиони слики за обработка. Можеме ли да го прошириме овој гасовод со буџет од 5.000 евра?“ Ова е прашање со кое ќе се соочите во секој вистински проект. Компјутерски трошоци На нашите 10.000 слики со еден GPU, тоа траеше околу 30 минути. Feature extraction 4,000,000 images ÷ 10,000 images × 30 min = 12,000 min = 200 GPU-hours На ~ €2/час за инстанца на облак GPU (Azure NC-серија со T4 или A10 GPU), тоа е околу . €400 Исто така, потребна е адаптација. Стандардните K-Means ги вчитаат сите податоци во меморијата за да пресметаат растојанија. Со 4M вградувања од 2048 димензии (секој 4-бајт плута): Clustering 4,000,000 × 2,048 × 4 bytes = ~32 GB just for the embeddings Тоа нема да се вклопи во РАМ на повеќето машини. од scikit-learn, кој ги обработува податоците во парчиња од (на пример) 10.000 примероци во исто време. MiniBatchKMeans Пред-тренинг на 4М псевдо-означени слики бара околу 50 GPU-часови . CNN training €100 Трошоци за складирање Raw слики: 4M × ~50 KB просек = Вградување: 4M × 2048 × 4 бајтови = На Azure Blob Storage на ~€0.02/GB/месец, тоа е за . 200 GB 32 GB €5/month Стратегија за етикетирање Ако 200 етикети не се доволни по скала, можеме да означиме повеќе. На ~ € 1 по слика (вклучувајќи контрола на квалитетот), дополнителни 2.000 етикети ќе чинат Но, постои и попаметен пристап: . €2,000 active learning Активното учење му овозможува на моделот да избере Наместо случајно да избере 2.000 слики, моделот ги идентификува оние за кои е најнесигурен – сликите кои ќе го научат најмногу. Која Со активно учење, можеби ни требаат само 500 дополнителни етикети наместо 2.000 . €500 Вкупна проценка на буџетот Feature extraction (GPU): €400 CNN training (GPU): €100 Storage (year 1): €60 Additional labeling: €500 – €2,000 ────────────────────────────────────── TOTAL: €1,060 – €2,560 Добро во рамките на буџетот од 5.000 евра, со простор за експериментирање и повторување. Пет услови за успех Користете облак GPUs, а не локален хардвер. Изнајми по час, плаќаат само за она што го користите. Користете MiniBatchKMeans наместо редовни KMeans. Истиот квалитет, 100x помалку меморија. Изградете соодветен податочен канал со обработка на партиите. Никогаш не ставајте 4M слики во РАМ одеднаш. Користете PyTorch DataLoader со num_workers > 0 за паралелно полнење. Размислете за активно учење за да ја максимизирате вредноста на секоја човечка означена слика. Магазин и верзија вградувања, не само сурови слики. Повторно извлекување 4М вградувања чини € 400; полнење на зачувани вградувања не чини ништо. Понатамошно читање : Понатамошно читање : MiniBatchKMeans (scikit-learn) — how to label smarter, not more Active learning overview MiniBatchKMeans (напишете го) Активен преглед на учењето Заклучок Полунадзорното учење не е магија – тоа е инженеринг. Вие ја земате структурата скриена во не-етикетирани податоци (преку вградување и групирање), ја претворите во приближни етикети, и ги користите за да му дадете на вашиот надгледуван модел почеток. Ајде да го прегледаме целиот гасовод што го изградивме: Истражување – Скениравме 10.000 слики за корупција, несоодветни формати и нерамнотежа на класи. Пред-обработка – Ние стандардизиравме секоја слика во формат ResNet50 очекува: 224×224, RGB, CLAHE-подобрена, ImageNet-нормализирана. Екстракција на карактеристики – Користевме претрениран ResNet50 за да ја претвориме секоја слика во 2048-димензионално вградување кое ја фаќа нејзината визуелна суштина. Кластерирање – ги применивме K-Means и DBSCAN за да ги групираме не-означените слики во кластери, а потоа да им доделуваме псевдо-ознаки врз основа на членството во кластерот. Полу-надгледувана обука – Пред-трениравме CNN на 9.800 псевдо-означени слики, потоа фино прилагодивме на 200 вистински етикети и ги споредивме со само надгледувана почетна линија. Анализа на скалација – Го проценивме трошоците за пресметување, складирање и етикетирање на 4М слики, потврдувајќи ја изводливоста во рамките на буџетот од 5.000 евра. Клучот на Takeaways: Претренираниот CNN може да извлече значајни карактеристики од било кој домен на слики, дури и оној на кој никогаш не бил обучен. Класификацијата на вградувањата открива природни групирања кои често одговараат на вистински класи. Псевдо-етикетите се несовршени, но моделот кој е претходно обучен на несовршени етикети, а потоа фино прилагоден на вистински етикети, го надминува моделот кој е обучен само на вистински етикети. И полу-надзорниот пристап е највреден токму кога етикетите се ретки и скапи – што е ситуацијата со која ќе се соочите во повеќето реални проекти. Моделот работи во различни области: медицинска слика, контрола на квалитетот во индустријата, сателитски слики, класификација на документи и мониторинг на биодиверзитетот.