Normalization

Detail modul normalization.py - statistik Rataan & SB, mode static/dynamic/hybrid, threshold.

Modul: Normalization

Source: backend/app/services/normalization.py Lihat juga: Alur Dynamic Normalization, Konsep → Mode Tryout

Tanggung Jawab

Hitung Rataan (mean) dan SB (standard deviation) dari distribusi NM siswa untuk dipakai di rumus NN:

text
NN = 500 + 100 × ((NM - Rataan) / SB)

Tiga mode operasi: static (manual), dynamic (real-time), hybrid (auto-switch).

Fungsi Inti

apply_normalization — Hitung NN

python
def apply_normalization(nm: int, rataan: float, sb: float) -> int:
    """
    NN = 500 + 100 × ((NM - Rataan) / SB)
    Output di-clip ke [0, 1000]
    """
    if not 0 <= nm <= 1000:
        raise ValueError(f"nm must be in range [0, 1000], got {nm}")

    if sb <= 0:
        return 500  # Edge case: semua skor sama

    z_score = (nm - rataan) / sb
    nn = 500 + 100 * z_score
    return max(0, min(1000, round(nn)))

Skala output: mean = 500, SD = 100. Siswa dengan NM tepat di Rataan → NN = 500.

calculate_dynamic_stats — Baca Rataan & SB Saat Ini

python
async def calculate_dynamic_stats(db, website_id, tryout_id):
    """Ambil (rataan, sb) terkini dari TryoutStats."""
    stats = await db.execute(
        select(TryoutStats).where(...)
    )
    return stats.rataan, stats.sb

update_dynamic_normalization — Update Inkremental

python
async def update_dynamic_normalization(db, website_id, tryout_id, nm: int):
    """
    Update TryoutStats dengan NM peserta baru:
    - participant_count += 1
    - total_nm_sum += nm
    - total_nm_sq_sum += nm * nm
    - Recompute rataan & sb dari running sums
    """
    if not 0 <= nm <= 1000:
        raise ValueError(f"nm must be in range [0, 1000], got {nm}")

    stats = await get_or_create_stats(...)

    stats.participant_count += 1
    stats.total_nm_sum += nm
    stats.total_nm_sq_sum += nm * nm
    stats.min_nm = min(stats.min_nm, nm)
    stats.max_nm = max(stats.max_nm, nm)

    # Recompute
    n = stats.participant_count
    mean = stats.total_nm_sum / n
    variance = (stats.total_nm_sq_sum / n) - (mean ** 2)
    sb = math.sqrt(max(0, variance))   # clamp anti floating-point negatif

    stats.rataan = mean
    stats.sb = sb
    return stats.rataan, stats.sb

Rumus Statistik

Mean (Rataan)

text
Rataan = total_nm_sum / participant_count
       = (1/n) × Σ NMᵢ

Standard Deviation (SB)

Pakai population SD (bukan sample SD):

text
Variance = (total_nm_sq_sum / n) - mean²
         = (1/n) × Σ NMᵢ² - mean²
         = E[NM²] - E[NM]²

SB = √Variance

Kenapa population SD? Karena data adalah seluruh populasi peserta tryout (bukan sample dari populasi lebih besar).

Stabilitas Numerik

python
variance = max(0.0, variance)  # Clamp anti negatif

Floating-point arithmetic bisa menghasilkan variance negatif yang sangat kecil (mis. -1e-15) ketika semua skor identik. Clamp ke 0 mencegah sqrt(-x) crash.

Mode Operasi

static (default)

Rataan & SB ditentukan manual admin via static_rataan (default 500.0) dan static_sb (default 100.0).

Use case: Tryout baru, peserta < min_sample_for_dynamic (default 100), belum ada cukup data.

dynamic

Rataan & SB dihitung real-time dari TryoutStats (semua NM peserta yang sudah selesai).

Use case: Peserta ≥ min_sample_for_dynamic, ingin distribusi aktual.

hybrid (auto-switch)

python
async def get_normalization_params(db, website_id, tryout_id):
    mode = tryout.normalization_mode

    if mode == "static":
        return tryout.static_rataan, tryout.static_sb

    if mode == "dynamic":
        if stats.participant_count < tryout.min_sample_for_dynamic:
            # Fallback ke static kalau data belum cukup
            return tryout.static_rataan, tryout.static_sb
        return stats.rataan, stats.sb

    if mode == "hybrid":
        # Otomatis: static di awal, dynamic saat cukup data
        if stats.participant_count < tryout.min_sample_for_dynamic:
            return tryout.static_rataan, tryout.static_sb
        return stats.rataan, stats.sb

Fungsi Pendukung

FungsiTujuan
get_normalization_mode(db, ...)Baca normalization_mode dari Tryout
check_threshold_for_dynamic(db, ...)Cek apakah participant_count ≥ threshold
get_normalization_params(db, ...)Ambil (rataan, sb) berdasarkan mode
calculate_skewness(db, ...)Hitung skewness distribusi NM (untuk laporan)
validate_dynamic_normalization(...)Validasi hasil sebelum dipakai

Skewness (untuk Laporan)

python
async def calculate_skewness(db, website_id, tryout_id) -> Optional[float]:
    """
    Skewness = E[(NM - μ)³] / σ³

    Mengindikasikan asymmetri distribusi:
    - skewness > 0: distribusi miring ke kanan (banyak skor rendah)
    - skewness < 0: distribusi miring ke kiri (banyak skor tinggi)
    - skewness ≈ 0: distribusi simetris
    """

Dipakai admin untuk evaluasi kualitas soal: kalau skewness ekstrem, mungkin distribusi kesulitan soal tidak seimbang.

Model: TryoutStats

Tabel yang menyimpan running sums (lihat Data → Data Models):

FieldTipeArti
participant_countintJumlah peserta yang sudah selesai
total_nm_sumfloatΣ NM semua peserta
total_nm_sq_sumfloatΣ NM² semua peserta
rataanfloatMean NM (ter-cache)
sbfloatSD NM (ter-cache)
min_nmintSkor terendah
max_nmintSkor tertinggi
last_calculateddatetimeUpdate terakhir

Edge Cases

KasusPenanganan
participant_count = 0calculate_dynamic_stats return (None, None)
participant_count = 1SB = 0.0 (tidak ada spread)
Semua peserta skor samaVariance = 0, SB = 0 → apply_normalization return 500
sb ≤ 0apply_normalization return 500 (z-score tidak terdefinisi)
nm di luar [0, 1000]Raise ValueError
Mode dynamic, data < thresholdFallback ke static_rataan/static_sb

Migrasi Static → Dynamic

sequenceDiagram
    autonumber
    participant Admin
    participant API
    participant DB

    Admin->>API: PUT /tryout/id/normalization
    Note over Admin,API: body: { normalization_mode: dynamic }
    API->>DB: UPDATE tryouts SET normalization_mode=dynamic
    API->>API: Baca TryoutStats, cek threshold
    alt participant_count >= min_sample_for_dynamic
        API-->>Admin: Mode aktif, pakai stats.rataan/sb
    else belum cukup data
        API-->>Admin: Mode disimpan, fallback ke static sementara
        Note over API: Tampilkan readiness: 67/100 peserta
    end

Penting: Sesi yang sedang berjalan tidak terpengaruh perubahan mode (snapshot saat mulai sesi).

Dependency

  • math (sqrt)
  • sqlalchemy async session
  • Model: Tryout, TryoutStats

Bacaan Lanjutan

Last updated Jul 25, 2026