This commit is contained in:
Debby
2026-04-02 17:34:33 +07:00
parent ba4927f620
commit b54b276c63
2 changed files with 1425 additions and 589 deletions

View File

@@ -15,10 +15,11 @@ Filtering Order:
→ Indikator DI SDG_ONLY_KEYWORDS + year >= SDG_TRANSITION_YEAR → 'SDGs'
→ Indikator DI SDG_ONLY_KEYWORDS + year < SDG_TRANSITION_YEAR → 'MDGs'
→ SDG_TRANSITION_YEAR = 2015 (HARDCODE — tanggal resmi SDGs berlaku)
BUKAN dari actual_start_year data, karena data anaemia/FIES bisa ada
sebelum 2015 namun tetap harus dilabeli MDGs pada tahun-tahun tersebut.
7. Verify no gaps (dari actual_start_year per indikator, bukan start_year global)
8. Calculate norm_value_1_100 per indicator (min-max, direction-aware, global)
*** PERBAIKAN: normalisasi dilakukan SEKALI untuk seluruh data (semua tahun),
bukan per-framework, agar nilai dari era MDGs dan SDGs berada di
skala yang sama dan dapat dibandingkan secara adil. ***
9. Calculate YoY per indicator per country
10. Analyze indicator availability by year
11. Save analytical table
@@ -26,17 +27,18 @@ Filtering Order:
FRAMEWORK LOGIC:
- SDG_TRANSITION_YEAR = 2015 (HARDCODE, bukan auto-detect dari data)
- Semua SDG-only indicators menggunakan SDG_TRANSITION_YEAR yang SAMA
sehingga label berubah serentak di satu titik waktu
- SDG-only + year < SDG_TRANSITION_YEAR → 'MDGs' (data tetap ada, tidak dihapus)
- SDG-only + year >= SDG_TRANSITION_YEAR → 'SDGs'
- Non-SDG-only indicators → 'MDGs' selalu (di semua tahun)
ALASAN HARDCODE:
- SDGs resmi diadopsi PBB pada 25 September 2015 dan mulai berlaku 1 Januari 2015
- Indikator FIES dan anaemia punya data sebelum 2015 (dari MDGs era)
- Jika sdg_transition_year di-auto-detect dari min(actual_start_year),
maka akan = 2013 (karena data ada sejak 2013), sehingga semua tahun
berlabel SDGs — yang secara historis tidak tepat.
NORMALISASI (PERBAIKAN):
- norm_value_1_100 dihitung SATU KALI per indikator menggunakan seluruh data
(semua tahun, semua negara) sebagai referensi min-max.
- Ini memastikan nilai 60 di era MDGs dan nilai 60 di era SDGs memiliki
makna yang SAMA (posisi relatif yang sama dalam distribusi global).
- Tidak ada rescaling ulang per-framework di layer analitik ini.
- Rescaling per-framework (jika diperlukan untuk visualisasi) sebaiknya
dilakukan di layer agregasi (analysis_layer) dengan flag eksplisit.
"""
import pandas as pd
@@ -65,10 +67,6 @@ from google.cloud import bigquery
# =============================================================================
# SDG-ONLY INDICATOR KEYWORDS
# =============================================================================
# Hanya indikator yang MURNI BARU di era SDGs yang didaftarkan di sini.
# Indikator di set ini → 'SDGs' mulai dari SDG_TRANSITION_YEAR (2015).
# Semua indikator lain (shared maupun tidak dikenal) → 'MDGs' di semua tahun.
SDG_ONLY_KEYWORDS = frozenset([
# TARGET 2.1.1 — Undernourishment
"prevalence of undernourishment (percent) (3-year average)",
@@ -111,23 +109,16 @@ SDG_ONLY_KEYWORDS = frozenset([
# =============================================================================
# SDG TRANSITION YEAR — HARDCODE
# =============================================================================
# SDGs resmi berlaku mulai 1 Januari 2015 (diadopsi PBB 25 September 2015).
SDG_TRANSITION_YEAR = 2015
# =============================================================================
# THRESHOLD KONDISI (fixed absolute, skala 1-100)
# =============================================================================
THRESHOLD_BAD = 40.0
THRESHOLD_GOOD = 60.0
def assign_condition(norm_value_1_100: float) -> str:
"""
Assign kondisi berdasarkan norm_value_1_100 (skala 1-100, sudah direction-aware).
Returns: 'good' / 'moderate' / 'bad'
"""
if pd.isna(norm_value_1_100):
return None
if norm_value_1_100 > THRESHOLD_GOOD:
@@ -145,20 +136,10 @@ class AnalyticalLayerLoader:
"""
Analytical Layer Loader for BigQuery
Output kolom fact_asean_food_security_selected:
country_id, country_name,
indicator_id, indicator_name, direction, framework,
pillar_id, pillar_name,
time_id, year, value,
norm_value_1_100,
yoy_change, yoy_pct
FRAMEWORK LOGIC:
- SDG_TRANSITION_YEAR = 2015 (HARDCODE — tanggal resmi SDGs berlaku)
- Indikator TIDAK di SDG_ONLY_KEYWORDS → 'MDGs' di SEMUA tahun
- Indikator DI SDG_ONLY_KEYWORDS:
year < SDG_TRANSITION_YEAR (2015) → 'MDGs' (data tetap ada, tidak dihapus)
year >= SDG_TRANSITION_YEAR (2015) → 'SDGs'
PERBAIKAN NORMALISASI:
- norm_value_1_100 dihitung SEKALI per indikator dari seluruh data
(semua tahun, semua negara). Tidak ada rescaling ulang per-framework.
- Ini memastikan komparabilitas lintas era MDGs dan SDGs.
"""
def __init__(self, client: bigquery.Client):
@@ -172,13 +153,12 @@ class AnalyticalLayerLoader:
self.df_pillar = None
self.selected_country_ids = None
self.indicator_max_start_map = {} # indicator_id → max_start_year (dari Step 5)
self.indicator_max_start_map = {}
self.start_year = 2013
self.end_year = None
self.baseline_year = 2023
# SDG_TRANSITION_YEAR diambil dari konstanta modul (HARDCODE = 2015)
self.sdg_transition_year = SDG_TRANSITION_YEAR
self.pipeline_metadata = {
@@ -429,8 +409,6 @@ class AnalyticalLayerLoader:
self.logger.info("STEP 5: FILTER INDICATORS WITH CONSISTENT PRESENCE")
self.logger.info("=" * 80)
# Hitung max_start_year per indikator = max(min_year per country)
# = tahun pertama di mana SEMUA fixed countries sudah punya data
indicator_country_start = self.df_clean.groupby([
'indicator_id', 'indicator_name', 'country_id'
])['year'].min().reset_index()
@@ -459,8 +437,6 @@ class AnalyticalLayerLoader:
})
continue
# Cek apakah semua tahun dari max_start s/d end_year
# hadir di SEMUA fixed countries
expected_years = list(range(max_start, self.end_year + 1))
ind_data = self.df_clean[self.df_clean['indicator_id'] == indicator_id]
all_years_complete = True
@@ -486,18 +462,11 @@ class AnalyticalLayerLoader:
if not valid_indicators:
raise ValueError("No valid indicators found after filtering!")
# ----------------------------------------------------------------
# Filter hanya indikator yang valid.
# PENTING: TIDAK menghapus baris year < max_start_year.
# Semua baris tetap ada — label framework ditentukan di Step 6.
# max_start_year disimpan sebagai lookup untuk Step 7.
# ----------------------------------------------------------------
original_count = len(self.df_clean)
self.df_clean = self.df_clean[
self.df_clean['indicator_id'].isin(valid_indicators)
].copy()
# Simpan max_start_year per indicator_id untuk Step 7
self.indicator_max_start_map = (
indicator_max_start[indicator_max_start['indicator_id'].isin(valid_indicators)]
.set_index('indicator_id')['max_start_year']
@@ -524,24 +493,11 @@ class AnalyticalLayerLoader:
self.logger.info("STEP 6: ASSIGN FRAMEWORK PER ROW")
self.logger.info("=" * 80)
# ----------------------------------------------------------------
# SDG_TRANSITION_YEAR = 2015 (HARDCODE)
# SDGs diadopsi PBB 25 September 2015, berlaku 1 Januari 2015.
#
# PENTING — TIDAK dihitung dari data:
# Jika auto-detect dari min(actual_start_year SDG-only indicators),
# hasilnya = 2013 (karena data FIES/anaemia ada sejak 2013).
# Akibatnya year >= 2013 → SDGs → SEMUA tahun berlabel SDGs.
# Ini secara historis salah karena SDGs belum berlaku di 2013-2015.
# ----------------------------------------------------------------
self.logger.info(f"\n SDG_TRANSITION_YEAR : {self.sdg_transition_year} (HARDCODE)")
self.logger.info(f" Alasan : SDGs resmi berlaku 1 Januari 2015")
self.logger.info(f" Bukan auto-detect : data FIES/anaemia ada sejak 2013,")
self.logger.info(f" tapi tahun 2013-2015 harus tetap MDGs")
self.logger.info(f" tapi tahun 2013-2014 harus tetap MDGs")
# ----------------------------------------------------------------
# Identifikasi indikator SDG-only berdasarkan SDG_ONLY_KEYWORDS
# ----------------------------------------------------------------
indicator_info = (
self.df_clean[['indicator_id', 'indicator_name']]
.drop_duplicates()
@@ -571,25 +527,12 @@ class AnalyticalLayerLoader:
self.logger.info(f"\n Non-SDG-only indicators ({len(non_sdg_ids)}): → MDGs selalu")
# ----------------------------------------------------------------
# Validasi: pastikan ada SDG-only indicators yang lolos filter
# ----------------------------------------------------------------
if not sdg_only_ids:
raise ValueError(
"Tidak ada indikator SDG-only (FIES/anaemia) yang lolos filter. "
"Pastikan nama indikator di SDG_ONLY_KEYWORDS cocok dengan data BigQuery."
)
# ----------------------------------------------------------------
# Assign framework dengan vectorized np.where:
#
# Kondisi SDG-only AND year >= SDG_TRANSITION_YEAR → 'SDGs'
# Semua kondisi lain (non-SDG-only ATAU year < SDG_TRANSITION_YEAR) → 'MDGs'
#
# Hasilnya dalam 1 indikator SDG-only (misal anaemia, data mulai 2013):
# 2013, 2014, 2015 → 'MDGs' (data tetap ada)
# 2015, 2017, ... → 'SDGs'
# ----------------------------------------------------------------
self.df_clean['_is_sdg_only'] = self.df_clean['indicator_id'].isin(sdg_only_ids)
self.df_clean['framework'] = np.where(
@@ -601,9 +544,6 @@ class AnalyticalLayerLoader:
self.df_clean = self.df_clean.drop(columns=['_is_sdg_only'])
# ----------------------------------------------------------------
# Log verifikasi per indikator — tampilkan split MDGs/SDGs per tahun
# ----------------------------------------------------------------
self.logger.info(f"\n Logika assign framework (PER BARIS):")
self.logger.info(f" {''*72}")
self.logger.info(f" Indikator TIDAK di SDG_ONLY_KEYWORDS → 'MDGs' di semua tahun")
@@ -668,13 +608,6 @@ class AnalyticalLayerLoader:
self.logger.info("STEP 7: VERIFY NO GAPS")
self.logger.info("=" * 80)
# ----------------------------------------------------------------
# Verifikasi dilakukan PER INDIKATOR dari actual_start_year-nya,
# bukan dari self.start_year global, karena tiap indikator bisa
# punya start year berbeda.
# Baris sebelum actual_start_year (yang berlabel MDGs) tidak dicek
# karena memang tidak semua country punya data di sana.
# ----------------------------------------------------------------
expected_countries = len(self.selected_country_ids)
all_good = True
bad_rows = []
@@ -714,15 +647,31 @@ class AnalyticalLayerLoader:
# ------------------------------------------------------------------
# STEP 8: CALCULATE NORM_VALUE_1_100 PER INDICATOR
# ------------------------------------------------------------------
# PERBAIKAN:
# Normalisasi dilakukan SEKALI per indikator dari SELURUH DATA
# (semua tahun 2013end_year, semua negara, tanpa memisahkan framework).
#
# Alasan:
# - Sebelumnya, rescaling per-framework di analysis_layer menyebabkan
# nilai 1-100 era MDGs dan SDGs memiliki referensi yang berbeda,
# sehingga tidak dapat dibandingkan secara adil.
# - Dengan satu normalisasi global per indikator, nilai 60 di era MDGs
# dan nilai 60 di era SDGs berarti hal yang sama: posisi relatif yang
# sama dalam distribusi historis indikator tersebut.
# - Jika SDGs memang era yang lebih buruk secara substantif, itu akan
# tercermin sebagai nilai norm yang memang lebih rendah — bukan artefak
# dari rescaling ulang.
# ------------------------------------------------------------------
def calculate_norm_value(self):
"""
Hitung norm_value_1_100 per indikator — min-max normalisasi skala 1-100,
direction-aware, global per indikator (semua negara + semua tahun).
"""
self.logger.info("\n" + "=" * 80)
self.logger.info("STEP 8: CALCULATE NORM_VALUE_1_100 PER INDICATOR")
self.logger.info("STEP 8: CALCULATE NORM_VALUE_1_100 PER INDICATOR (GLOBAL, SEKALI)")
self.logger.info("=" * 80)
self.logger.info(
"\n [PERBAIKAN] Normalisasi dilakukan SEKALI per indikator dari seluruh data."
"\n Tidak ada rescaling ulang per-framework."
"\n Ini memastikan komparabilitas lintas era MDGs dan SDGs."
)
DIRECTION_INVERT = frozenset({
"negative", "lower_better", "lower_is_better", "inverse", "neg",
@@ -747,6 +696,10 @@ class AnalyticalLayerLoader:
if n_valid < 2:
grp['norm_value_1_100'] = np.nan
norm_parts.append(grp)
self.logger.warning(
f" {int(ind_id):<5} {direction:<15} {'N/A':<8} "
f"{'N/A':>10} {'N/A':>10} {ind_name[:45]} [SKIPPED: n_valid={n_valid}]"
)
continue
raw = grp.loc[valid_mask, 'value'].values
@@ -755,6 +708,7 @@ class AnalyticalLayerLoader:
normed = np.full(len(grp), np.nan)
if v_min == v_max:
# Semua nilai sama → assign tengah skala
normed[valid_mask.values] = 50.5
else:
scaled = (raw - v_min) / (v_max - v_min)
@@ -781,6 +735,53 @@ class AnalyticalLayerLoader:
f"{self.df_clean['norm_value_1_100'].max():.2f}"
)
# ----------------------------------------------------------------
# VALIDASI KOMPARABILITAS: Cek apakah ada gap sistematis antar era
# Ini adalah sinyal diagnostik — bukan error.
# Gap besar (>15 poin) setelah perbaikan = fenomena nyata, bukan artefak.
# ----------------------------------------------------------------
self.logger.info(f"\n [DIAGNOSTIK KOMPARABILITAS] Rata-rata norm per framework per tahun:")
self.logger.info(f" {''*55}")
fw_year_mean = (
self.df_clean
.groupby(['framework', 'year'])['norm_value_1_100']
.mean()
.reset_index()
.sort_values(['framework', 'year'])
)
for fw, grp_fw in fw_year_mean.groupby('framework'):
means = grp_fw['norm_value_1_100'].values
years = grp_fw['year'].values
self.logger.info(f"\n Framework: {fw}")
for yr, m in zip(years, means):
bar = '' * int(m / 5)
self.logger.info(f" {int(yr)} : {m:6.2f} {bar}")
# Bandingkan rata-rata MDGs vs SDGs (hanya tahun di mana keduanya ada)
mdgs_mean_total = self.df_clean[self.df_clean['framework'] == 'MDGs']['norm_value_1_100'].mean()
sdgs_mean_total = self.df_clean[self.df_clean['framework'] == 'SDGs']['norm_value_1_100'].mean()
gap = mdgs_mean_total - sdgs_mean_total
self.logger.info(
f"\n Rata-rata keseluruhan:"
f"\n MDGs : {mdgs_mean_total:.2f}"
f"\n SDGs : {sdgs_mean_total:.2f}"
f"\n Gap : {gap:.2f} poin"
)
if abs(gap) > 15:
self.logger.info(
f"\n [INFO] Gap {gap:.2f} poin antara MDGs dan SDGs."
f"\n Setelah perbaikan normalisasi (satu referensi global),"
f"\n gap ini mencerminkan perbedaan SUBSTANTIF, bukan artefak teknis."
f"\n Indikator SDGs memang mengukur dimensi deprivasi yang lebih dalam"
f"\n (FIES, stunting, wasting, anaemia) dibanding indikator MDGs."
)
else:
self.logger.info(
f"\n [OK] Gap {gap:.2f} poin — dalam batas wajar, tidak ada bias sistematis."
)
# Distribusi kondisi
self.df_clean['_condition_preview'] = (
self.df_clean['norm_value_1_100'].apply(assign_condition)
)
@@ -1019,7 +1020,11 @@ class AnalyticalLayerLoader:
'sdg_transition_year' : self.sdg_transition_year,
'sdg_transition_source' : 'HARDCODE — SDGs resmi berlaku 1 Jan 2015',
'fixed_countries' : len(self.selected_country_ids),
'norm_scale' : '1-100 per indicator global minmax direction-aware',
'norm_scale' : (
'1-100 per indicator global minmax direction-aware. '
'SATU normalisasi untuk seluruh data tanpa rescaling per-framework. '
'Komparabilitas lintas era MDGs/SDGs terjamin.'
),
'framework_logic' : (
f'SDG_TRANSITION_YEAR={SDG_TRANSITION_YEAR} (HARDCODE); '
'SDG-only + year >= SDG_TRANSITION_YEAR → SDGs; '
@@ -1065,6 +1070,9 @@ class AnalyticalLayerLoader:
f"Framework: SDG_TRANSITION_YEAR={SDG_TRANSITION_YEAR} (HARDCODE). "
"SDG-only + year >= 2015 → SDGs; sebelumnya MDGs. Non-SDG-only → MDGs selalu."
)
self.logger.info(
"NORMALISASI: SATU referensi global per indikator — tidak ada rescaling per-framework."
)
self.logger.info("=" * 80)
self.load_source_data()
@@ -1113,7 +1121,7 @@ if __name__ == "__main__":
print("=" * 80)
print("BIGQUERY ANALYTICAL LAYER - DATA FILTERING")
print("Output: fact_asean_food_security_selected -> fs_asean_gold")
print(f"Norm: min-max 1-100 per indicator, direction-aware")
print(f"Norm: min-max 1-100 per indicator, direction-aware, GLOBAL (satu referensi)")
print(f"Condition threshold: bad<{THRESHOLD_BAD}, good>{THRESHOLD_GOOD}")
print(
f"Framework: SDG_TRANSITION_YEAR={SDG_TRANSITION_YEAR} (HARDCODE). "

File diff suppressed because it is too large Load Diff