الشبكات العصبيةintermediateمعالج رسوميات اختياري~30 دقيقةColab

كشف الشذوذ بالمُرمِّز الذاتي

Anomaly detection with an autoencoder

خطأ إعادة البناء بوصفه مسافة

لديك خمسة آلاف تسجيل لنبضات القلب، لكن التسميات شبه معدومة للحالات غير الطبيعية، لأنها بالضبط ما لم يجمع منه أحد ما يكفي. وهذا هو الوضع المعتاد في : الفئة النادرة في الواقع نادرة في بيانات التدريب أيضاً، فلا يجد المصنّف ما يتعلّم منه. لذلك نغيّر الخطة. ندرّب نموذجاً على الحالات الطبيعية وحدها، وحين يعجز عن إعادة بناء شيء لم يألفه، يصبح هذا العجز نفسه هو إشارة الإنذار.

الهدف

درّب مُرمِّزاً ذاتياً على النبضات الطبيعية فقط، ثم اختر العتبة من توزيع خطأ إعادة البناء لا بالتخمين، وحقّق قيمة F1 لا تقل عن 0.90 على مجموعة اختبار منفصلة تضم الفئتين معاً.

يفتح Colab نسخة للقراءة فقط. احفظ نسخة في Drive للاحتفاظ بتعديلاتك.

يحتاج الدفتر إلى لوحة مفاتيح — يُفضَّل فتحه على حاسوب مكتبي.

الأوراق وراء هذه الورشة

في العادي تحتاج أمثلة من كل فئة حتى يتعلّم النموذج التمييز بينها. أما في كشف الشذوذ فلا تملك هذه الأمثلة. الحالات الشاذة نادرة بطبيعتها، فتعاني البيانات من بشكل حادّ. والقليل المتاح منها لا يمثّل كل الأشكال التي قد تواجهها لاحقاً.

لذلك نقلب السؤال. بدل أن نعلّم النموذج شكل الشذوذ، نعلّمه شكل الوضع الطبيعي. نبني مهمّته سهلة: أن يعيد إنتاج مدخلاته كما هي. لكنّنا نجبر البيانات على المرور عبر ضيّق لا يتّسع لنسخ كل التفاصيل. ولأن المساحة محدودة، سيصرفها النموذج على ما يتكرّر كثيراً في بيانات التدريب، أي على الأنماط الطبيعية. أعطه بعد ذلك شيئاً غير مألوف، وسيفشل في إعادة بنائه. وهذا الفشل تحديداً هو إشارة الإنذار.

تهيئة أوّلية: نتأكد من جاهزية البيئة وسلامة البيانات قبل بدء التدريب.

import azimuth_nb as azimuth

env = azimuth.setup(SLUG, lang=LANG, profile=PROFILE)
شيفرة الورشة
كشف الشذوذ بمرمّز ذاتي
بدون معالج رسوميات · ذاكرة 3.9 غ.ب · PyTorch 2.2.2+cu121
الملف: ⁦free⁩
البيانات:
  · ecg5000.csv — already present
جاهز · ⁦epochs=160, batchSize=128, latentDim=8, hidden=[64, 32], learningRate=0.001, seed=17⁩

انتبه للتقسيم: مجموعة التدريب لا تحتوي إلا على تسجيلات طبيعية. وهذه هي الطريقة كلّها.

import numpy as np

raw = np.loadtxt(env.assets["ecg5000.csv"], delimiter=",")
traces, labels = raw[:, :-1].astype(np.float32), raw[:, -1].astype(int)

# Min-max to [0, 1] using TRAINING statistics only. Fitting the scaler on
# everything would leak the abnormal range into the normal model — a quiet
# mistake that inflates every number downstream.
rng = np.random.default_rng(env.cfg["seed"])
order = rng.permutation(len(traces))
traces, labels = traces[order], labels[order]

split = int(0.8 * len(traces))
train_all, test_x = traces[:split], traces[split:]
train_labels, test_y = labels[:split], labels[split:]

# THE METHOD, IN ONE LINE: the model only ever sees normal traces.
train_x = train_all[train_labels == 1]

lo, hi = train_x.min(), train_x.max()
train_x = (train_x - lo) / (hi - lo)
test_x = (test_x - lo) / (hi - lo)

n_normal = int((test_y == 1).sum())
n_abnormal = int((test_y == 0).sum())
class_balance = {
    "trainNormal": len(train_x),
    "testNormal": n_normal,
    "testAbnormal": n_abnormal,
}

if env.lang == "ar":
    print(f"التدريب: {len(train_x)} أثراً طبيعياً فقط")
    print(f"الاختبار: {n_normal} طبيعي · {n_abnormal} شاذ")
else:
    print(f"train: {len(train_x)} normal traces only")
    print(f"test:  {n_normal} normal · {n_abnormal} abnormal")
شيفرة الورشة
التدريب: 2338 أثراً طبيعياً فقط
الاختبار: 581 طبيعي · 419 شاذ
import torch
import torch.nn as nn

torch.manual_seed(env.cfg["seed"])
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

n_features = train_x.shape[1]


class Autoencoder(nn.Module):
    """Symmetric encoder/decoder around a deliberately narrow layer.

    The bottleneck width is the entire experiment. Widen it and the model
    learns the identity function and reconstructs abnormal traces just as well
    as normal ones — at which point there is no detector left, only a copier.
    """

    def __init__(self, n_features: int, hidden: list[int], latent: int):
        super().__init__()
        widths = [n_features, *hidden]

        encoder: list[nn.Module] = []
        for a, b in zip(widths[:-1], widths[1:]):
            encoder += [nn.Linear(a, b), nn.ReLU()]
        encoder += [nn.Linear(widths[-1], latent), nn.ReLU()]
        self.encoder = nn.Sequential(*encoder)

        decoder: list[nn.Module] = []
        rev = [latent, *hidden[::-1]]
        for a, b in zip(rev[:-1], rev[1:]):
            decoder += [nn.Linear(a, b), nn.ReLU()]
        # Sigmoid because the inputs were scaled to [0, 1]: the output range
        # should be able to reach the input range and no further.
        decoder += [nn.Linear(rev[-1], n_features), nn.Sigmoid()]
        self.decoder = nn.Sequential(*decoder)

    def forward(self, x):
        return self.decoder(self.encoder(x))


model = Autoencoder(n_features, list(env.cfg["hidden"]), env.cfg["latentDim"]).to(device)
param_count = sum(p.numel() for p in model.parameters())

env.explain("bottleneck")
if env.lang == "ar":
    print(f"عنق الزجاجة: {env.cfg['latentDim']} من أصل {n_features} بُعداً · {param_count:,} معامل")
else:
    print(f"bottleneck: {env.cfg['latentDim']} of {n_features} dims · {param_count:,} parameters")
شيفرة الورشة
⁦bottleneck⁩ — أضيق طبقة في المرمّز الذاتي. عرضها هو الميزانية: على النموذج أن يصف المدخل كله بهذا العدد من الأرقام.
عنق الزجاجة: 8 من أصل 140 بُعداً · 22,868 معامل

راقب شكل المنحنى، لا الرقم الأخير وحده. تهبط الخسارة بسرعة في البداية، ثم تستقر على هضبة لعشرين أو ثلاثين حقبة، ثم تعاود الانخفاض. ما يحدث عند الهضبة أن النموذج انتهى من تحسين الشكل المتوسط للنبضة، وبعدها بدأ يلتقط التفاصيل التي كان يطمسها بهذا المتوسط. لو أوقفت التدريب عند الهضبة، فستنشر كاشفاً لم يكتمل تدريبه، مع أنه يبدو وكأنه وصل إلى التقارب.

from torch.utils.data import DataLoader, TensorDataset

train_t = torch.from_numpy(train_x).to(device)
loader = DataLoader(
    TensorDataset(train_t, train_t),
    batch_size=env.cfg["batchSize"],
    shuffle=True,
)

optimizer = torch.optim.Adam(model.parameters(), lr=env.cfg["learningRate"])
criterion = nn.MSELoss()

loss_curve = []
model.train()
for epoch in range(env.cfg["epochs"]):
    total = 0.0
    for batch_x, batch_y in loader:
        optimizer.zero_grad()
        loss = criterion(model(batch_x), batch_y)
        loss.backward()
        optimizer.step()
        total += loss.item() * len(batch_x)
    epoch_loss = total / len(train_t)
    loss_curve.append(epoch_loss)
    if epoch % 10 == 0 or epoch == env.cfg["epochs"] - 1:
        print(f"epoch {epoch:3d}  loss {epoch_loss:.5f}")

final_loss = loss_curve[-1]
شيفرة الورشة
epoch   0  loss 0.02346
epoch  10  loss 0.00169
epoch  20  loss 0.00169
epoch  30  loss 0.00168
epoch  40  loss 0.00166
epoch  50  loss 0.00160
epoch  60  loss 0.00123
epoch  70  loss 0.00114
epoch  80  loss 0.00093
epoch  90  loss 0.00075
epoch 100  loss 0.00071
epoch 110  loss 0.00070
epoch 120  loss 0.00068
epoch 130  loss 0.00066
epoch 140  loss 0.00062
epoch 150  loss 0.00058
epoch 159  loss 0.00057

هذا هو الرسم الذي بُنيت الورشة كلها من أجله: توزيعان، والمسافة بينهما.

import matplotlib.pyplot as plt


def reconstruction_error(x: np.ndarray) -> np.ndarray:
    """Mean absolute error per trace — one number for each recording.

    L1 rather than L2 on purpose: squared error lets a single badly-missed
    sample dominate a trace's score, which makes the threshold sensitive to
    noise rather than to shape.
    """
    model.eval()
    with torch.no_grad():
        t = torch.from_numpy(x).to(device)
        return torch.mean(torch.abs(model(t) - t), dim=1).cpu().numpy()


train_errors = reconstruction_error(train_x)
test_errors = reconstruction_error(test_x)

normal_errors = test_errors[test_y == 1]
abnormal_errors = test_errors[test_y == 0]
separation = float(abnormal_errors.mean() / normal_errors.mean())

env.explain("reconstruction error")
fig, ax = plt.subplots(figsize=(7, 3.6))
bins = np.linspace(0, float(np.percentile(test_errors, 99.5)), 60)
ax.hist(normal_errors, bins=bins, alpha=0.75, label="normal", color="#2a9d8f")
ax.hist(abnormal_errors, bins=bins, alpha=0.75, label="abnormal", color="#e76f51")
ax.set_xlabel("reconstruction error (MAE)")
ax.set_ylabel("traces")
ax.legend()
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
plt.show()

print(f"normal mean   {normal_errors.mean():.4f}")
print(f"abnormal mean {abnormal_errors.mean():.4f}")
separation_ok = env.check("separation", separation)
شيفرة الورشة
⁦reconstruction error⁩ — مقدار بُعد المخرج عن المدخل. يكون صغيراً على البيانات التي دُرّب عليها النموذج، وكبيراً على ما لم يألفه، وهذه الفجوة هي الكاشف.
normal mean   0.0146
abnormal mean 0.0452
✓ خطأ الشاذ منسوباً إلى خطأ الطبيعي: 3.103 (المطلوب ≥ 2.5)

تمرين

حدّد بنفسك. أول ما يخطر بالبال هو تجريب قيم مختلفة حتى يبلغ أعلى قيمة له. لكن هذا يعني أنك تستعمل تسميات الحالات الشاذة، ولن تكون هذه التسميات متاحة لك يوم تنشر النموذج فعلياً. استخدم أخطاء التدريب بدلاً من ذلك: اختر من توزيع الأخطاء على بيانات سبق أن رآها النموذج، واشرح لماذا اخترت هذا الرقم.

بعد ذلك تأمّل ما تقوله الأرقام أعلاه. تدريب النموذج 160 حقبة بدل 60 رفع نسبة الفصل من 2.5× إلى 3.1×، أي أن التوزيعين تباعدا فعلاً. ومع ذلك لم تتحرّك قيمة F1. السبب أن ارتفعت بقدر ما انخفض ، فتعادل المكسب والخسارة.

ماذا يعني ذلك؟ الكاشف لم يعد محدوداً بجودة النموذج، بل بالعتبة التي تختارها في هذه الخلية. ابحث عن عتبة ترفع F1 فوق 0.949، ولاحظ أنك فعلت ذلك دون إعادة تدريب أي شيء.

# YOUR TURN.
#
# Pick a percentile of `train_errors` — the errors on traces the model was
# trained on. Anything you can compute from this array is available on the day
# you deploy, with no abnormal examples in hand. Anything you compute from
# `abnormal_errors` is not.
#
# Start here and change the number, with a reason:
PERCENTILE = 95

threshold = float(np.percentile(train_errors, PERCENTILE))
print(f"threshold = {threshold:.4f}  (p{PERCENTILE} of training error)")
شيفرة الورشة

يتوفّر تلميح في الدفتر — env.hint(2)

predicted_abnormal = test_errors > threshold
actually_abnormal = test_y == 0

tp = int((predicted_abnormal & actually_abnormal).sum())
fp = int((predicted_abnormal & ~actually_abnormal).sum())
fn = int((~predicted_abnormal & actually_abnormal).sum())
tn = int((~predicted_abnormal & ~actually_abnormal).sum())

precision = tp / (tp + fp) if (tp + fp) else 0.0
recall = tp / (tp + fn) if (tp + fn) else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
confusion = {"tp": tp, "fp": fp, "fn": fn, "tn": tn}

if env.lang == "ar":
    print(f"الدقة {precision:.3f} · الاستدعاء {recall:.3f} · F1 {f1:.3f}")
    print(f"أخطأ في {fn} حالة شاذة، وأنذر زوراً في {fp} حالة طبيعية")
else:
    print(f"precision {precision:.3f} · recall {recall:.3f} · F1 {f1:.3f}")
    print(f"missed {fn} abnormal · false-alarmed on {fp} normal")

f1_ok = env.check("f1", f1)
شيفرة الورشة
الدقة 0.925 · الاستدعاء 0.969 · F1 0.946
أخطأ في 13 حالة شاذة، وأنذر زوراً في 33 حالة طبيعية
✓ مقياس F1 على مجموعة الاختبار: 0.9464 (المطلوب ≥ 0.9)

حقّقت غابة العزل قيمة F1 قدرها 0.780، مقابل 0.946 للمُرمِّز الذاتي. الهدف من المقارنة ليس تحديد الفائز. السؤال الحقيقي: أيّهما كنت ستنشر فعلاً في بيئة الإنتاج؟

from sklearn.ensemble import IsolationForest

forest = IsolationForest(
    n_estimators=100,
    contamination=n_abnormal / len(test_y),
    random_state=env.cfg["seed"],
)
forest.fit(train_x)
forest_abnormal = forest.predict(test_x) == -1

b_tp = int((forest_abnormal & actually_abnormal).sum())
b_fp = int((forest_abnormal & ~actually_abnormal).sum())
b_fn = int((~forest_abnormal & actually_abnormal).sum())
b_precision = b_tp / (b_tp + b_fp) if (b_tp + b_fp) else 0.0
b_recall = b_tp / (b_tp + b_fn) if (b_tp + b_fn) else 0.0
baseline_f1 = (
    2 * b_precision * b_recall / (b_precision + b_recall) if (b_precision + b_recall) else 0.0
)

print(f"isolation forest F1 {baseline_f1:.3f}   ·   autoencoder F1 {f1:.3f}")
شيفرة الورشة
isolation forest F1 0.780   ·   autoencoder F1 0.946

الإيصال: رمز الإتمام والأرقام التي بُني عليها.

receipt = env.receipt()
شيفرة الورشة
اكتملت الورشة.

رمز الإتمام: ⁦AZ-██████████⁩
الصقه في صفحة الورشة على أزيموث لتسجيل إتمامها.
آخر تحقّق: 2026-08-25 · CPU · PyTorch 2.2.2+cu121 · Python 3.12.3 · unknown

مصطلحات هذه الورشة