Table of Contents שולחן התוכן תוויות: תוויות יקרות, תמונות חינם חלק 1 - חקר נתונים: הבנת הנתונים שאתה עובד איתם חלק 2 – Preprocessing: Speaking the Model’s Language חלק 3 — תמצית תכונות: הפוך תמונות למספרים משמעותיים חלק 4 – קבוצות ללא פיקוח: גילוי מבנה בחושך חלק 5 - אימון חצי מנוהל: הניסוי הבסיסי חלק 6 – גודל למיליוני תמונות: מפת דרכים מציאותית מסקנה תוויות יקרות, תמונות חינם בעולם המושלם, לכל תמונה במסד הנתונים שלך תהיה תווית. "דלקתית." "נורמלית." "סוג שברק A." "סוג שברק B." אבל בעולם האמיתי, תווית היא יקרות מאוד. רופא רפואי אחד יכול לתווית אולי 50 סריקות מוח לשעה - ב -200 € / שעה. מפקח איכות תעשייתי יכול לציין אולי 100 חלקים לשעה. הנה הפרדוקס: לחברות יש לעתים קרובות מיליוני תמונות (מצלמות, חיישנים, הורדות משתמשים) אבל הם יכולים להרשות לעצמם רק תווית חלקיק קטן. במקום לזרוק את התמונות הלא מסומנות, אנו משתמשים בהן כדי לשפר את המודל – תוך שילוב של קבוצה קטנה עם קבוצה גדולה שלא מסומנת. semi-supervised learning תן לי להשתמש באנלוגיה. לדמיין שאתה מורה עם 30 תלמידים. אתה נותן להם את כל הבחינה, אבל יש לך רק זמן להעריך 5 ניירות. אתה דירוג את אלה 5 בקפידה, ואתה מזהה דפוסים: סטודנטים שכתבו הרבה נוטים לקבל ציונים גבוהים, וסטודנטים שהשאירו תשובות ריקות נוטים לקבל ציונים נמוכים. באמצעות דפוסים אלה, אתה יכול להעריך את הציונים של 25 ניירות אחרות מבלי לקרוא כל שורה. מאמר זה בונה צינור סיווג תמונה חצי מנוהל מראש.אנו עובדים דרך כל שלב עם הסברים מפורטים ככל שאנחנו הולכים. מחקר מקרה: זיהוי פגמים הייצור על פני השטחים המתכת. מפעל מייצר צלחות פלדה, מצלמות צילום כל צלחת כפי שהיא מתגלגלת מן קו הייצור. מחקר מקרה : מפעל מייצר צלחות פלדה, מצלמות לצלם כל צלחת כפי שהיא מתגלגלת מהקו הייצור.רוב הצלחות הם נורמליים, חלקם יש פגמים (צלקות, שברים, פיטורים, קצוות). 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] ההבנה המרכזית: נהפוך תמונות ללא תווית לתמונות תחשבו על זה כמו לתת לתלמיד מדריך ללימוד גס לפני הבחינה האמיתית - זה לא יהיה מושלם, אבל זה טוב יותר מכלום. בערך בואו גם להיות ספציפיים לגבי המילון שאנחנו נשתמש לאורך מאמר זה, כי שילוב של מונחים הוא מקור נפוץ מאוד לבלבול: תוויות חזקות (או פשוט "תוויות"): תמונות עם תוויות שנבדקו על ידי מומחה אנושי. תוויות חלשות (או "pseudo-labeled"): תמונות אשר התוויות הוכחו על ידי קבוצה. זול יותר אבל רעש יותר. ללא תוויות: תמונות ללא תוויות בכלל.זה המצב שלהם לפני שאנו מערבבים אותם. Embedding: סיכום דיגיטלי קומפקטי של תמונה, המיוצר על ידי רשת נוירלית מעודכנת. קריאה נוספת : קריאה נוספת : סקירה כללית של למידה חצי מנוהלת - 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" אנו משתמשים במקום concatenation string כי זה מתמודד עם מפרקים נתיב כראוי על כל מערכת הפעלה. 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 — סריקה לבעיות: רזולוציה, צבע, שחיתות לאחר מכן, אנחנו צריכים לבדוק כל תמונה בנפרד. קובץ פגום יחיד יכול להתמוטט כל אימון ריצה. רזולוציות לא עקביות יפרצו את התמונות אם אתה לא זהיר. אנו כותבים פונקציה קטנה שמנסה לפתוח כל תמונה ולרשום את התכונות שלה: 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, } The שדה זה חשוב במיוחד. 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 – קבוצת נתונים של פגמים פני השטח של פלדה בעולם האמיתי שאתה יכול להתאמן עם תיעוד כרית מסד הנתונים Surface Defect חלק 2 – Preprocessing: Speaking the model’s language למה אנחנו לא יכולים פשוט להזין תמונות גלובליות לרשת נוירלית CNN מראש כמו ResNet50 הוכשרו על סוג מאוד ספציפי של כניסה: 224×224 תמונות פיקסל, בצבע RGB, נורמליזציה עם ערכים ממוצעים ספציפיים וסטנדרטי deviation חישובים ממסד נתונים ImageNet. תחשוב על זה כמו שפה. ResNet50 "דבר ImageNet." אם אנחנו רוצים שזה יבין את התמונות על פני השטח המתכת שלנו, אנחנו צריכים "לתרגם" אותם לתבנית ImageNet תחילה. להמיר ל- RGB (3 ערוצים) הגדלת ניגודיות באמצעות שוויון היסטוגרם גודל: 224×224 נורמליזציה של ערכי פיקסלים כדי להתאים את הסטטיסטיקה של ImageNet 2.1 - מהי שוויון היסטוגרם, ומדוע זה משנה כאן? ההבדל בין משטח רגיל לבין מגרד יכול להיות רק כמה רמות אינטנסיביות פיקסלים - בלתי נראים לעין עיוורת, וקשה מאוד עבור מודל לזהות. מחלק מחדש את אינטנסיביות הפיקסלים כך שהגבול המלא (0 עד 255) משמש באופן שווה.התוצאה: תכונות עדינות "פופ" חזותית ומספרית. Histogram equalization אנו משתמשים בגירסה מתקדמת יותר בשם (Contrast Limited Adaptive Histogram Equalization). שלא כמו שוויון גלובלי (שנוגע לאותה טרנספורמציה לתמונה כולה), CLAHE מחולק את התמונה לתמונות קטנות (8×8 כברירת מחדל) ומשווה כל צלחת באופן עצמאי. CLAHE הנה ההדמיה: תארו לעצמכם שאתם מתאימים את ההבהרות בתמונה.השוויון הגלובלי דומה לשימוש במקלדת בהבהרות אחת עבור התמונה כולה – תוכלו להאיר את הפינות האפלות אך לשטוף את המרכז כבר בהיר.CLAHE דומה להתאים את ההבהרות בכל אזור באופן עצמאי, כך שכל חלק מהתמונה יהיה ברור. 2.2 בניית קבוצת נתונים PyTorch מותאמת אישית PyTorch מארגן את טעינת הנתונים סביב שני כיתות: (יודע כיצד להוריד פריט אחד) ו (יודע כיצד לספוג ולרכוב פריטים).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 מצפה לשלושה ערוצים.אם התמונה שלנו היא בקנה מידה אפור (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 (High × Width × Channels) ל-CHW (Channels × Height × Width), מה ש-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] יצירת Objects של 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") DataLoader משולב תמונות יחד (batch_size=32 פירושו 32 תמונות בו זמנית) ואופציונלי shuffles אותם: 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 ערוצים). רובם הם רעש - שינויים קטנים באור, חפצים חיישנים, חפצים דחיסה. גרוע יותר: שתי תמונות של בדיוק אותו שבר, נלקחו מנקודות זווית שונות או תאורה, יש ערכים פיקסלים שונים לחלוטין. מה שאנחנו צריכים הוא ייצוג שמקבל את של התמונה - "זה נראה כמו צלקת", "זה משטח רך" - בצורת מספר compact, יציבה. עשה 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 ל-PyTorch יש שני יתרונות: זה מונע שינוי אקראי של משקלים מראש מאומנים, וזה הופך את ההסבר מהיר יותר (אין מעקב גרדינט = פחות חישוב וזיכרון). 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) אנחנו רוצים את הווקטור הממוצע של 2048 משטח ממוצע משותף שכבה - זהו ההרכבה שלנו.אנחנו לא רוצים את שכבה המלאה האחרונה, כי זה ספציפי ל 1000 הכיתות של ImageNet (כלב, חתול, מטוס ...) ובלתי שימושי עבור המשימה שלנו. feature_extractor = torch.nn.Sequential(*list(resnet.children())[:-1]) מה עושה קו זה: החזרת כל שכבות ResNet כרשימה. כל השלבים חוץ מהשורה האחרונה (השורה האחרונה). מחזירים אותם למודל. 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 לבסוף, אנו מעבירים את התוצאות חזרה ל-CPU (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.זהו דחיסה של 73x בהשוואה לפיקסלים גלויים (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, תמונה מושחתת כנראה צלצל דרך שלב הניקוי. הולך יותר רחוק: הולך יותר רחוק: Transfer Learning Explained (תרגול PyTorch) מסמך ResNet (He et al., 2015) - האדריכלות המקורית שהציגה חיבורים קופצים מיצוי תכונות לעומת התאמה מעולה — הערות קורס סטנפורד CS231n Transfer Learning Explained (תרגול PyTorch) עיתון ResNet (He et al., 2015) תגית: fine-tuning חלק 4 – קבוצות ללא פיקוח: גילוי מבנה בחושך 4.1 - מה clustering עושה ולמה אנחנו צריכים את זה כעת יש לנו 10,000 שקיות – סיכום מספרי קומפקטי של כל תמונה. 200 מהם יש תוויות. בנתונים שאנו מקווים להתאים "נורמלי" ו "חסר". 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) The הפרמטר קובץ בערך את "גודל השכונה" - כמה נקודות קרובות 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-Means הוא האלגוריתם הקבוצתי הפשוט והפופולרי ביותר, והוא מחלק את הנתונים לקבוצות k בדיוק על-ידי הקצאת כל נקודה למרכז הקבוצות הקרוב ביותר, ולאחר מכן עדכון המרכזים. מכיוון שאנחנו יודעים שיש לנו 2 כיתות (נורמליות וחסרות), אנו מתחילים עם k = 2. חוסר היכולת ליצור קבוצות נפרדות). סוגים כדי להעריך עד כמה הקבוצות תואמות את התוויות האמיתיות, אנו משתמשים 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}") The פרמטר פירושו K-Means ימשיך לפעול 10 פעמים עם התחלות אקראיות שונות ולשמור על התוצאה הטובה ביותר. n_init=10 אם k=2 נותן את ARI הגבוה ביותר, זה מאשר הנתונים שלנו יש שתי קבוצות טבעיות שמתאימות עם נורמלי vs פגום. 4.6 – DBSCAN clustering (גישה חלופית) 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-reduced ( DBSCAN נאבקת בממדים גבוהים מאוד כי כל המרחקים הופכים דומים (הקללה של מימד). all_pca השווה את ARI הטוב ביותר מ DBSCAN עם הטוב ביותר מ K-Means, ולבחור את הזוכה. 4.7 - ויזואליזציה של הקבוצות על קטע t-SNE בואו נראה איך הכי טוב clustering נראה על שלנו 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 - הקצאת pseudo-labels לתמונות ללא תוויות עכשיו השלב הקריטי: אנו לוקחים את הפקודות של הקבוצות ומחליטים עליהן כ"תוויות חלשות" עבור התמונות הלא מסומנות.אבל יש פירוט - K-Means מקצה את מזהות הקבוצות באופן אקראי.קבוצת 0 עשויה להתאים ל"חסר" או "נורמלי". לחצו כאן על Pseudo-Label: 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 explained (ללמוד מה זה אומר) DBSCAN הסביר (scikit-learn) כיצד לקרוא t-SNE כראוי (Distill) - קריאה חיונית K-Means explained (ללמוד מה זה אומר) DBSCAN הסביר (scikit-learn) כיצד לקרוא t-SNE כראוי (Distill) חלק 5 - הכשרה חצי מנוהלת: הניסוי האמיתי 5.1 – ההיגיון שמאחורי הגישה השנייה שלנו הנה האינטואיציה.דמיין שאתה מאמן מפקח איכות חדש במפעל: אתה מראה להם 9,800 תמונות ואומר "אני אלה הם נורמליים והם פגומים, אבל אני לא בטוח ב-100 אחוזים." המפקח מתחיל ליצור מודל נפשי אגרסיבי. חלק מהתוויות שגויות, אבל הדפוס הכללי - משטחים נורמליים הם רכים ואחדים, משטחים פגומים יש פגמים - הוא בדרך כלל נכון. . Phase 1 (pre-training on pseudo-labels) חושב אינטואיציה לאחר מכן אתה מראה להם 200 תמונות שנבדקו בקפידה על ידי מומחה: "אלה נורמליות בהחלט, והן פגומות בהחלט." Phase 2 (fine-tuning on real labels) תוצאות חיפוש: שוטר שיש לו 10,000 תמונות (בניית אינטואיציה רחבה) והיו על ידי 200 דוגמאות מוכחות על ידי מומחים (באמצעות דיוק).אנו מצפים שהמפקח הזה יעלה על אחד שמעולם ראה רק את 200 הדוגמאות מוכחות. ראה calibrated כדי להוכיח זאת, אנו מבצעים שני ניסויים מקבילים: ניסוי A – רק תחת פיקוח: תרגול על 200 תמונות עם תוויות בלבד ניסוי B – חצי מנוהל: אימון מראש על 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 מיליון פרמטרים, overfitting הוא האיום העיקרי. Dropout מפעיל באופן אקראי 50% של נוירונים במהלך כל שלב אימון, הכריח את הרשת ללמוד ייצוגים מיותרים. בזמן ההשלכות, כל הנוירונים פעילים. Dropout(0.5) למה עבור דירוג בינארי, נוירון יציאה יחיד עם פעולת סיגמואיד שווה במתמטיקה לשני נוירונים עם softmax, אבל פשוט יותר ויותר יציב במספר. Linear(2048, 1) 5.3 - הפונקציה האובדן: טיפול חוסר איזון בכיתה לפני כתיבת מעגל ההכשרה, בואו נדבר על הפונקציה האובדן. (Binary Cross-Entropy with Logits), המשלב פעולת sigmoid עם cross-entropy בינארי בפעולה אחת, יציבה במספר. 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) זה יפחית באופן אוטומטי את קצב הלמידה כאשר אובדן האימות מפסיק להשתפר. המשמעות היא "מתין שלוש תקופות ללא שיפור לפני הפחתה". משמעות הדבר היא "לרבות את שיעור הלמידה ב- 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 כל קבוצה עוברת דרך מחזור הקלאסי: forward pass → calculate loss → backpropagate gradients → update weights → reset gradients עבור הסדרה הבאה. (אחרי כל תקופה של אימון): 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()) שימו לב זה חשוב: זה מפעיל את dropout (כל הנוירונים פעילים) ומשתמש בסטטיסטיקות המבוצעות עבור נורמליזציה של המלאי במקום סטטיסטיקות של המלאי. 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 אנו עוקבים אחר מודלים לעתים קרובות להגיע לפסגה לפני סוף האימון (לאחר מכן הם עשויים להתאים מעט יותר). best 5.7 – הכנת חלוקת הנתונים אנו מחלקים את הנתונים המסומנים לתוך רכבת (70%) ובדיקת (30%). זה אף פעם לא משמש לאימון בשני הניסויים.Stratification מבטיח את יחס הכיתה נשמר בשני חלקיקים. 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, ) על ידי DataLoaders: 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 – ניסוי A: רק תחת פיקוח (בסיס) זהו הניסוי הפשוט יותר.אנו מאמנים מודל חדש באמצעות רק את 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 – ניסוי B: חצי מנוהל (הגישה בשתי השלבים) שלב 1 נותן למודל אינטואיציה רחבה מתוך 9,800 תמונות מזוייפות. שלב 2 מחזק אותו עם 140 תוויות אמיתיות. 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 לעומת 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: שיפור צנוע. Semi-supervised עוזר אבל השווי קטן. F1 לא השתנה או גרוע יותר: התוויות הפסודיות היו רועשות מדי כדי לעזור, או שהקבוצות לא הצליחו לתפוס את המבנה האמיתי. קריאה נוספת : קריאה נוספת : מסמך Pseudo-labelling (Lee, 2013) – הגישה המקורית סקר למידה חצי מנוהל (van Engelen & Hoos) – סקירה מקיפה של שיטות אימון PyTorch Best Practices מסמך על תוויות מזויפות (Lee, 2013) סקר למידה חצי מנוהל (van Engelen & Hoos) אימון PyTorch Best Practices חלק 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 At ~€2/hour for a cloud GPU instance (Azure NC-series with a T4 or A10 GPU), that's about . €400 סטנדרטי K-Means מעלה את כל הנתונים לזיכרון כדי לחשב מרחקים.עם 4M השבבים של 2048 מימדים (כל אחד 4-אייט פלואט): Clustering 4,000,000 × 2,048 × 4 bytes = ~32 GB just for the embeddings זה לא יתאים ל-RAM ברוב המכונות. פתרון: להשתמש מ- scikit-learn, אשר מעבד נתונים בחתיכות של (לומר) 10,000 דגימות בכל פעם. MiniBatchKMeans הכשרה מוקדמת על 4M תמונות pseudo-labeled לוקח כ 50 שעות GPU . CNN training €100 עלויות אחסון תמונה חדה: 4M × ~50 KB ממוצע = כניסה: 4M × 2048 × 4 bytes = על אחסון Azure Blob ב ~€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 €, עם מקום לניסויים ופעולות מחדש. חמישה תנאים להצלחה השתמש ב-GPU בענן, לא בחומרה מקומית.שכור לפי שעה, לשלם רק עבור מה שאתה משתמש בו. השתמש MiniBatchKMeans במקום KMeans רגילים. אותה איכות, 100x פחות זיכרון. לבנות צינור נתונים מתאים עם עיבוד מנות. לעולם אל תעלו תמונות 4M ל-RAM בבת אחת. השתמש ב-PyTorch DataLoader עם num_workers > 0 עבור טעינה מקבילה. שים לב למידה פעילה כדי למקסם את הערך של כל תמונה עם תווית אנושית. חנות וגרסאות השבבים, לא רק תמונות גלובליות. לחלץ מחדש 4M השבבים עולה € 400; לטעון השבבים מאוחסנים לא עולה כלום. קריאה נוספת : קריאה נוספת : MiniBatchKMeans (scikit-learn) — how to label smarter, not more Active learning overview MiniBatchKMeans (ללמוד את זה) סקירה מלאה של Learning Overview מסקנה למידה חצי מנוהלת אינה קסם – זו הנדסה.אתה לוקח את המבנה הנסתר בנתונים ללא תווית (באמצעות השבבים והקבוצות), הופך אותו לתוויות משותפות, ומשתמש בהם כדי לתת למודל המנוהל שלך התחלה ראשונה. בואו נחזור על הצינור המלא שאנחנו בונים: חקירה – סורקנו 10,000 תמונות עבור שחיתות, פורמטים לא עקביים, וחוסר איזון של כיתות. עיבוד מראש – סטנדרטיזנו כל תמונה בפורמט ResNet50 מצפה: 224×224, RGB, CLAHE-enhanced, ImageNet-normalized. תמצית תכונות – השתמשנו ResNet50 מראש כדי להמיר כל תמונה לתוך מוטבע 2048-ממדי שמקלט את המהות החזותית שלה. Clustering – היינו מיישמים את K-Means ו-DBSCAN כדי לקבץ תמונות ללא תוויות לקבוצות, ולאחר מכן הקבצנו pseudo-labels בהתבסס על חברויות לקבוצות. הכשרה חצי-מנוהלת - הכרנו מראש את CNN על 9,800 תמונות עם תוויות מזויפות, לאחר מכן התאמה מעמיקה על 200 תוויות אמיתיות, והשוותנו אותה לעומת קו היסוד המנוהל בלבד. ניתוח גודל – הערכנו את העלויות של חישוב, אחסון וסימן עבור 4M תמונות, המאשר את היעילות בתוך תקציב של 5,000 €. המפתחות המרכזיות של Takeaways: CNN מראש יכול לחלץ תכונות משמעותיות מכל תחום תמונה, אפילו אחד שהוא מעולם לא היה מאומן על. קבוצות על מוטבעות מגלה קבוצות טבעיות אשר לעתים קרובות מתאימות לכיתות אמיתיות. Pseudo-labels הם לא מושלמים, אבל מודל מראש מאומץ על תוויות לא מושלמות ולאחר מכן מתאים היטב על תוויות אמיתיות עולה על מודל מאומץ רק על תוויות אמיתיות. התבנית פועלת בתחומים שונים: דימוי רפואי, בקרת איכות תעשייתית, תמונת לווין, דירוג מסמכים ומעקב על מגוון ביולוגי.