Spaces:
Running
Running
Commit ·
570bb71
1
Parent(s): a7fc4d6
fix: geovision
Browse files
server/models/geovision_fusion_model.py
CHANGED
|
@@ -8,8 +8,8 @@ Cross-stacked ensemble fusion pipeline that combines:
|
|
| 8 |
4. Fusion Meta-Learner → final disaster prediction + weather regime prediction
|
| 9 |
|
| 10 |
Output classes:
|
| 11 |
-
Disaster: ['
|
| 12 |
-
Weather: ['
|
| 13 |
|
| 14 |
The fusion meta-learner builds a feature vector by concatenating the intermediate
|
| 15 |
probability outputs and embeddings, then feeds it through an XGBoost/CatBoost/LogReg
|
|
@@ -37,14 +37,8 @@ EMBEDDING_DIM = 128
|
|
| 37 |
HORIZON = 1
|
| 38 |
FORECAST_DAYS = SEQUENCE_LENGTH - HORIZON # 59
|
| 39 |
|
| 40 |
-
DISASTER_CLASSES = ['
|
| 41 |
-
WEATHER_REGIMES = ['
|
| 42 |
-
|
| 43 |
-
# Note: The fusion training script uses ['Normal','Flood','Storm','Drought','Landslide']
|
| 44 |
-
# but the LabelEncoder produces alphabetical order. The inference pipeline uses
|
| 45 |
-
# DISASTER_CLASSES = ['Normal','Flood','Storm','Drought','Landslide'] for display
|
| 46 |
-
# but internally the model predicts indices 0-4 in alphabetical order.
|
| 47 |
-
# We follow the alphabetical order as the ground truth from the LabelEncoder.
|
| 48 |
|
| 49 |
# 36 temporal features (17 raw meteorological + 19 engineered)
|
| 50 |
INPUT_FEATURES = [
|
|
@@ -107,6 +101,8 @@ class GeoVisionFusionModel:
|
|
| 107 |
self.fusion_weather_model = None
|
| 108 |
self.fusion_weather_scaler = None
|
| 109 |
self.fusion_feature_map = None
|
|
|
|
|
|
|
| 110 |
|
| 111 |
self.models_loaded = False
|
| 112 |
self._load_status = {}
|
|
@@ -249,6 +245,7 @@ class GeoVisionFusionModel:
|
|
| 249 |
pkg = joblib.load(dis_path)
|
| 250 |
self.fusion_disaster_model = pkg['model']
|
| 251 |
self.fusion_disaster_scaler = pkg.get('scaler', None)
|
|
|
|
| 252 |
logger.info(f"[GEOVISION] [OK] Disaster fusion ({pkg.get('model_name', '?')})")
|
| 253 |
loaded += 1
|
| 254 |
except Exception as e:
|
|
@@ -261,6 +258,7 @@ class GeoVisionFusionModel:
|
|
| 261 |
pkg = joblib.load(wx_path)
|
| 262 |
self.fusion_weather_model = pkg['model']
|
| 263 |
self.fusion_weather_scaler = pkg.get('scaler', None)
|
|
|
|
| 264 |
logger.info(f"[GEOVISION] [OK] Weather fusion ({pkg.get('model_name', '?')})")
|
| 265 |
loaded += 1
|
| 266 |
except Exception as e:
|
|
@@ -277,6 +275,12 @@ class GeoVisionFusionModel:
|
|
| 277 |
self.fusion_feature_map = None
|
| 278 |
else:
|
| 279 |
self.fusion_feature_map = json.loads(fmap_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
except Exception as e:
|
| 281 |
logger.warning(f"[GEOVISION] fusion_feature_map.json invalid: {e}; continuing without feature map")
|
| 282 |
self.fusion_feature_map = None
|
|
@@ -284,6 +288,25 @@ class GeoVisionFusionModel:
|
|
| 284 |
self._load_status['fusion'] = 'loaded' if loaded == 2 else f'partial ({loaded}/2)'
|
| 285 |
return 1 if loaded >= 1 else 0
|
| 286 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
# ────────────────────────────────────────────────────────────────
|
| 288 |
# LSTM PROCESSING
|
| 289 |
# ──────────────────────────────────���─────────────────────────────
|
|
@@ -496,7 +519,7 @@ class GeoVisionFusionModel:
|
|
| 496 |
try:
|
| 497 |
cnn_probs = self.cnn_model.predict(satellite_image, verbose=0)
|
| 498 |
logger.info(f"[GEOVISION] CNN probs shape: {cnn_probs.shape}")
|
| 499 |
-
logger.info(f"[GEOVISION] CNN top class: {
|
| 500 |
return cnn_probs
|
| 501 |
except Exception as e:
|
| 502 |
logger.error(f"[GEOVISION] CNN inference FAILED: {e}")
|
|
@@ -517,7 +540,7 @@ class GeoVisionFusionModel:
|
|
| 517 |
return proba
|
| 518 |
|
| 519 |
boosted = np.copy(proba)
|
| 520 |
-
normal_idx =
|
| 521 |
|
| 522 |
if boosted.ndim == 2:
|
| 523 |
boosted[0, normal_idx] *= boost_factor
|
|
@@ -548,11 +571,22 @@ class GeoVisionFusionModel:
|
|
| 548 |
"""
|
| 549 |
logger.info("[GEOVISION] STEP 3: Fusion Meta-Learner...")
|
| 550 |
|
| 551 |
-
# Stack features
|
| 552 |
parts = [lstm_dis_probs, lstm_wx_probs, lstm_embeddings]
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
parts.append(cnn_probs)
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
parts.append(ensemble_probs)
|
| 557 |
|
| 558 |
fusion_features = np.concatenate(parts, axis=1)
|
|
@@ -562,8 +596,21 @@ class GeoVisionFusionModel:
|
|
| 562 |
'fusion_features_dim': fusion_features.shape[1],
|
| 563 |
}
|
| 564 |
|
| 565 |
-
normal_idx =
|
| 566 |
-
min_disaster_threshold = 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
|
| 568 |
# ── Disaster prediction ──
|
| 569 |
if self.fusion_disaster_model is not None:
|
|
@@ -571,55 +618,52 @@ class GeoVisionFusionModel:
|
|
| 571 |
fusion_scaled = self._scale_fusion(fusion_features, self.fusion_disaster_scaler)
|
| 572 |
dis_proba_raw = self.fusion_disaster_model.predict_proba(fusion_scaled)
|
| 573 |
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
if sum_non_normal > 0:
|
| 588 |
-
res_p[non_normal_indices] = rem * (non_normal_raw / sum_non_normal)
|
| 589 |
-
else:
|
| 590 |
-
res_p[non_normal_indices] = rem / len(non_normal_indices)
|
| 591 |
-
|
| 592 |
pred_idx = normal_idx
|
| 593 |
dis_proba = np.array([res_p])
|
| 594 |
else:
|
| 595 |
-
|
| 596 |
-
|
|
|
|
|
|
|
|
|
|
| 597 |
|
| 598 |
-
result['disaster_prediction'] =
|
| 599 |
result['disaster_probabilities'] = {
|
| 600 |
-
cls: round(float(dis_proba[0, i]), 4) for i, cls in enumerate(
|
| 601 |
if i < dis_proba.shape[1]
|
| 602 |
}
|
| 603 |
result['disaster_confidence'] = round(float(dis_proba[0, pred_idx]), 4)
|
| 604 |
logger.info(f"[GEOVISION] Disaster: {result['disaster_prediction']} ({result['disaster_confidence']:.2%})")
|
| 605 |
except Exception as e:
|
| 606 |
logger.error(f"[GEOVISION] Disaster fusion FAILED: {e}")
|
| 607 |
-
|
| 608 |
-
boosted_lstm = self._apply_normal_class_boost(lstm_dis_probs, boost_factor=2.0)
|
| 609 |
idx = int(np.argmax(boosted_lstm[0]))
|
| 610 |
-
result['disaster_prediction'] =
|
| 611 |
result['disaster_probabilities'] = {
|
| 612 |
-
cls: round(float(boosted_lstm[0, i]), 4) for i, cls in enumerate(
|
| 613 |
if i < boosted_lstm.shape[1]
|
| 614 |
}
|
| 615 |
result['disaster_confidence'] = round(float(np.max(boosted_lstm[0])), 4)
|
| 616 |
result['disaster_source'] = 'lstm_fallback'
|
| 617 |
else:
|
| 618 |
-
boosted_lstm = self._apply_normal_class_boost(lstm_dis_probs, boost_factor=
|
| 619 |
idx = int(np.argmax(boosted_lstm[0]))
|
| 620 |
-
result['disaster_prediction'] =
|
| 621 |
result['disaster_probabilities'] = {
|
| 622 |
-
cls: round(float(boosted_lstm[0, i]), 4) for i, cls in enumerate(
|
| 623 |
if i < boosted_lstm.shape[1]
|
| 624 |
}
|
| 625 |
result['disaster_confidence'] = round(float(np.max(boosted_lstm[0])), 4)
|
|
@@ -631,9 +675,9 @@ class GeoVisionFusionModel:
|
|
| 631 |
fusion_wx_scaled = self._scale_fusion(fusion_features, self.fusion_weather_scaler)
|
| 632 |
wx_pred = self.fusion_weather_model.predict(fusion_wx_scaled)
|
| 633 |
wx_proba = self.fusion_weather_model.predict_proba(fusion_wx_scaled)
|
| 634 |
-
result['weather_prediction'] =
|
| 635 |
result['weather_probabilities'] = {
|
| 636 |
-
cls: round(float(wx_proba[0, i]), 4) for i, cls in enumerate(
|
| 637 |
if i < wx_proba.shape[1]
|
| 638 |
}
|
| 639 |
result['weather_confidence'] = round(float(np.max(wx_proba[0])), 4)
|
|
@@ -641,18 +685,18 @@ class GeoVisionFusionModel:
|
|
| 641 |
except Exception as e:
|
| 642 |
logger.error(f"[GEOVISION] Weather fusion FAILED: {e}")
|
| 643 |
idx = int(np.argmax(lstm_wx_probs[0]))
|
| 644 |
-
result['weather_prediction'] =
|
| 645 |
result['weather_probabilities'] = {
|
| 646 |
-
cls: round(float(lstm_wx_probs[0, i]), 4) for i, cls in enumerate(
|
| 647 |
if i < lstm_wx_probs.shape[1]
|
| 648 |
}
|
| 649 |
result['weather_confidence'] = round(float(np.max(lstm_wx_probs[0])), 4)
|
| 650 |
result['weather_source'] = 'lstm_fallback'
|
| 651 |
else:
|
| 652 |
idx = int(np.argmax(lstm_wx_probs[0]))
|
| 653 |
-
result['weather_prediction'] =
|
| 654 |
result['weather_probabilities'] = {
|
| 655 |
-
cls: round(float(lstm_wx_probs[0, i]), 4) for i, cls in enumerate(
|
| 656 |
if i < lstm_wx_probs.shape[1]
|
| 657 |
}
|
| 658 |
result['weather_confidence'] = round(float(np.max(lstm_wx_probs[0])), 4)
|
|
@@ -737,18 +781,18 @@ class GeoVisionFusionModel:
|
|
| 737 |
|
| 738 |
intermediate = {
|
| 739 |
'lstm_disaster_probs': {
|
| 740 |
-
cls: round(float(lstm_dis[0, i]), 4) for i, cls in enumerate(
|
| 741 |
if i < lstm_dis.shape[1]
|
| 742 |
},
|
| 743 |
'lstm_weather_probs': {
|
| 744 |
-
cls: round(float(lstm_wx[0, i]), 4) for i, cls in enumerate(
|
| 745 |
if i < lstm_wx.shape[1]
|
| 746 |
},
|
| 747 |
'models_used': models_used,
|
| 748 |
}
|
| 749 |
if ensemble_probs is not None:
|
| 750 |
intermediate['ensemble_disaster_probs'] = {
|
| 751 |
-
cls: round(float(ensemble_probs[0, i]), 4) for i, cls in enumerate(
|
| 752 |
if i < ensemble_probs.shape[1]
|
| 753 |
}
|
| 754 |
|
|
@@ -783,8 +827,8 @@ class GeoVisionFusionModel:
|
|
| 783 |
return {
|
| 784 |
'models_loaded': self.models_loaded,
|
| 785 |
'components': self._load_status,
|
| 786 |
-
'disaster_classes':
|
| 787 |
-
'weather_regimes':
|
| 788 |
'features': {
|
| 789 |
'temporal': len(INPUT_FEATURES),
|
| 790 |
'scalar': len(SCALAR_FEATURE_COLUMNS),
|
|
|
|
| 8 |
4. Fusion Meta-Learner → final disaster prediction + weather regime prediction
|
| 9 |
|
| 10 |
Output classes:
|
| 11 |
+
Disaster: ['Normal', 'Flood', 'Storm', 'Drought', 'Landslide']
|
| 12 |
+
Weather: ['Rainy', 'Dry', 'Humid', 'Stormy', 'Cloudy']
|
| 13 |
|
| 14 |
The fusion meta-learner builds a feature vector by concatenating the intermediate
|
| 15 |
probability outputs and embeddings, then feeds it through an XGBoost/CatBoost/LogReg
|
|
|
|
| 37 |
HORIZON = 1
|
| 38 |
FORECAST_DAYS = SEQUENCE_LENGTH - HORIZON # 59
|
| 39 |
|
| 40 |
+
DISASTER_CLASSES = ['Normal', 'Flood', 'Storm', 'Drought', 'Landslide']
|
| 41 |
+
WEATHER_REGIMES = ['Rainy', 'Dry', 'Humid', 'Stormy', 'Cloudy']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
# 36 temporal features (17 raw meteorological + 19 engineered)
|
| 44 |
INPUT_FEATURES = [
|
|
|
|
| 101 |
self.fusion_weather_model = None
|
| 102 |
self.fusion_weather_scaler = None
|
| 103 |
self.fusion_feature_map = None
|
| 104 |
+
self.disaster_classes = list(DISASTER_CLASSES)
|
| 105 |
+
self.weather_regimes = list(WEATHER_REGIMES)
|
| 106 |
|
| 107 |
self.models_loaded = False
|
| 108 |
self._load_status = {}
|
|
|
|
| 245 |
pkg = joblib.load(dis_path)
|
| 246 |
self.fusion_disaster_model = pkg['model']
|
| 247 |
self.fusion_disaster_scaler = pkg.get('scaler', None)
|
| 248 |
+
self.disaster_classes = self._class_names_from_package(pkg, self.disaster_classes)
|
| 249 |
logger.info(f"[GEOVISION] [OK] Disaster fusion ({pkg.get('model_name', '?')})")
|
| 250 |
loaded += 1
|
| 251 |
except Exception as e:
|
|
|
|
| 258 |
pkg = joblib.load(wx_path)
|
| 259 |
self.fusion_weather_model = pkg['model']
|
| 260 |
self.fusion_weather_scaler = pkg.get('scaler', None)
|
| 261 |
+
self.weather_regimes = self._class_names_from_package(pkg, self.weather_regimes)
|
| 262 |
logger.info(f"[GEOVISION] [OK] Weather fusion ({pkg.get('model_name', '?')})")
|
| 263 |
loaded += 1
|
| 264 |
except Exception as e:
|
|
|
|
| 275 |
self.fusion_feature_map = None
|
| 276 |
else:
|
| 277 |
self.fusion_feature_map = json.loads(fmap_text)
|
| 278 |
+
self.disaster_classes = list(
|
| 279 |
+
self.fusion_feature_map.get('disaster_classes') or self.disaster_classes
|
| 280 |
+
)
|
| 281 |
+
self.weather_regimes = list(
|
| 282 |
+
self.fusion_feature_map.get('weather_regimes') or self.weather_regimes
|
| 283 |
+
)
|
| 284 |
except Exception as e:
|
| 285 |
logger.warning(f"[GEOVISION] fusion_feature_map.json invalid: {e}; continuing without feature map")
|
| 286 |
self.fusion_feature_map = None
|
|
|
|
| 288 |
self._load_status['fusion'] = 'loaded' if loaded == 2 else f'partial ({loaded}/2)'
|
| 289 |
return 1 if loaded >= 1 else 0
|
| 290 |
|
| 291 |
+
def _class_names_from_package(self, pkg: Dict[str, Any], fallback: List[str]) -> List[str]:
|
| 292 |
+
"""Return class labels in the exact order stored with the model artifact."""
|
| 293 |
+
names = pkg.get('class_names')
|
| 294 |
+
if isinstance(names, (list, tuple)) and names:
|
| 295 |
+
return [str(name) for name in names]
|
| 296 |
+
return list(fallback)
|
| 297 |
+
|
| 298 |
+
def _expected_fusion_feature_count(self) -> Optional[int]:
|
| 299 |
+
"""Return the feature count expected by the loaded fusion artifact."""
|
| 300 |
+
for scaler in [self.fusion_disaster_scaler, self.fusion_weather_scaler]:
|
| 301 |
+
expected = getattr(scaler, 'n_features_in_', None)
|
| 302 |
+
if expected:
|
| 303 |
+
return int(expected)
|
| 304 |
+
if isinstance(self.fusion_feature_map, dict):
|
| 305 |
+
expected = self.fusion_feature_map.get('total_features')
|
| 306 |
+
if expected:
|
| 307 |
+
return int(expected)
|
| 308 |
+
return None
|
| 309 |
+
|
| 310 |
# ────────────────────────────────────────────────────────────────
|
| 311 |
# LSTM PROCESSING
|
| 312 |
# ──────────────────────────────────���─────────────────────────────
|
|
|
|
| 519 |
try:
|
| 520 |
cnn_probs = self.cnn_model.predict(satellite_image, verbose=0)
|
| 521 |
logger.info(f"[GEOVISION] CNN probs shape: {cnn_probs.shape}")
|
| 522 |
+
logger.info(f"[GEOVISION] CNN top class: {self.disaster_classes[int(np.argmax(cnn_probs[0]))]}")
|
| 523 |
return cnn_probs
|
| 524 |
except Exception as e:
|
| 525 |
logger.error(f"[GEOVISION] CNN inference FAILED: {e}")
|
|
|
|
| 540 |
return proba
|
| 541 |
|
| 542 |
boosted = np.copy(proba)
|
| 543 |
+
normal_idx = self.disaster_classes.index('Normal') if 'Normal' in self.disaster_classes else 0
|
| 544 |
|
| 545 |
if boosted.ndim == 2:
|
| 546 |
boosted[0, normal_idx] *= boost_factor
|
|
|
|
| 571 |
"""
|
| 572 |
logger.info("[GEOVISION] STEP 3: Fusion Meta-Learner...")
|
| 573 |
|
| 574 |
+
# Stack features in the order used by the saved fusion artifact.
|
| 575 |
parts = [lstm_dis_probs, lstm_wx_probs, lstm_embeddings]
|
| 576 |
+
expected_dim = self._expected_fusion_feature_count()
|
| 577 |
+
current_dim = sum(part.shape[1] for part in parts)
|
| 578 |
+
if (
|
| 579 |
+
cnn_probs is not None
|
| 580 |
+
and cnn_probs.shape[0] == lstm_dis_probs.shape[0]
|
| 581 |
+
and (expected_dim is None or current_dim + cnn_probs.shape[1] <= expected_dim)
|
| 582 |
+
):
|
| 583 |
parts.append(cnn_probs)
|
| 584 |
+
current_dim += cnn_probs.shape[1]
|
| 585 |
+
if (
|
| 586 |
+
ensemble_probs is not None
|
| 587 |
+
and ensemble_probs.shape[0] == lstm_dis_probs.shape[0]
|
| 588 |
+
and (expected_dim is None or current_dim + ensemble_probs.shape[1] <= expected_dim)
|
| 589 |
+
):
|
| 590 |
parts.append(ensemble_probs)
|
| 591 |
|
| 592 |
fusion_features = np.concatenate(parts, axis=1)
|
|
|
|
| 596 |
'fusion_features_dim': fusion_features.shape[1],
|
| 597 |
}
|
| 598 |
|
| 599 |
+
normal_idx = self.disaster_classes.index('Normal') if 'Normal' in self.disaster_classes else 0
|
| 600 |
+
min_disaster_threshold = 0.85
|
| 601 |
+
|
| 602 |
+
def _build_normal_dominant_probs(raw_probs, normal_target, non_normal_indices, normal_idx, n_classes):
|
| 603 |
+
"""Build probability array where Normal dominates, distributing remainder proportionally."""
|
| 604 |
+
res_p = np.zeros(n_classes)
|
| 605 |
+
res_p[normal_idx] = round(normal_target, 4)
|
| 606 |
+
rem = 1.0 - normal_target
|
| 607 |
+
nn_raw = raw_probs[0, non_normal_indices]
|
| 608 |
+
sum_nn = np.sum(nn_raw)
|
| 609 |
+
if sum_nn > 0:
|
| 610 |
+
res_p[non_normal_indices] = rem * (nn_raw / sum_nn)
|
| 611 |
+
else:
|
| 612 |
+
res_p[non_normal_indices] = rem / len(non_normal_indices)
|
| 613 |
+
return res_p
|
| 614 |
|
| 615 |
# ── Disaster prediction ──
|
| 616 |
if self.fusion_disaster_model is not None:
|
|
|
|
| 618 |
fusion_scaled = self._scale_fusion(fusion_features, self.fusion_disaster_scaler)
|
| 619 |
dis_proba_raw = self.fusion_disaster_model.predict_proba(fusion_scaled)
|
| 620 |
|
| 621 |
+
non_normal_indices = [i for i in range(len(self.disaster_classes)) if i != normal_idx]
|
| 622 |
+
max_non_normal_raw = float(np.max(dis_proba_raw[0, non_normal_indices]))
|
| 623 |
+
raw_pred_idx = int(np.argmax(dis_proba_raw[0]))
|
| 624 |
+
raw_norm = float(dis_proba_raw[0, normal_idx])
|
| 625 |
+
|
| 626 |
+
if raw_pred_idx == normal_idx:
|
| 627 |
+
dis_proba = dis_proba_raw
|
| 628 |
+
pred_idx = normal_idx
|
| 629 |
+
elif max_non_normal_raw < min_disaster_threshold:
|
| 630 |
+
normal_target = float(max(0.65, min(0.92, 0.95 - 0.35 * max_non_normal_raw)))
|
| 631 |
+
normal_target += (raw_norm - 0.10) * 0.10
|
| 632 |
+
normal_target = float(max(0.65, min(0.92, normal_target)))
|
| 633 |
+
res_p = _build_normal_dominant_probs(dis_proba_raw, normal_target, non_normal_indices, normal_idx, len(self.disaster_classes))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
pred_idx = normal_idx
|
| 635 |
dis_proba = np.array([res_p])
|
| 636 |
else:
|
| 637 |
+
disaster_excess = min(1.0, (max_non_normal_raw - min_disaster_threshold) / (1.0 - min_disaster_threshold))
|
| 638 |
+
normal_target = float(max(0.20, min(0.55, 0.55 - 0.35 * disaster_excess + raw_norm * 0.05)))
|
| 639 |
+
res_p = _build_normal_dominant_probs(dis_proba_raw, normal_target, non_normal_indices, normal_idx, len(self.disaster_classes))
|
| 640 |
+
pred_idx = int(np.argmax(res_p))
|
| 641 |
+
dis_proba = np.array([res_p])
|
| 642 |
|
| 643 |
+
result['disaster_prediction'] = self.disaster_classes[pred_idx] if pred_idx < len(self.disaster_classes) else f'Class_{pred_idx}'
|
| 644 |
result['disaster_probabilities'] = {
|
| 645 |
+
cls: round(float(dis_proba[0, i]), 4) for i, cls in enumerate(self.disaster_classes)
|
| 646 |
if i < dis_proba.shape[1]
|
| 647 |
}
|
| 648 |
result['disaster_confidence'] = round(float(dis_proba[0, pred_idx]), 4)
|
| 649 |
logger.info(f"[GEOVISION] Disaster: {result['disaster_prediction']} ({result['disaster_confidence']:.2%})")
|
| 650 |
except Exception as e:
|
| 651 |
logger.error(f"[GEOVISION] Disaster fusion FAILED: {e}")
|
| 652 |
+
boosted_lstm = self._apply_normal_class_boost(lstm_dis_probs, boost_factor=8.0)
|
|
|
|
| 653 |
idx = int(np.argmax(boosted_lstm[0]))
|
| 654 |
+
result['disaster_prediction'] = self.disaster_classes[idx]
|
| 655 |
result['disaster_probabilities'] = {
|
| 656 |
+
cls: round(float(boosted_lstm[0, i]), 4) for i, cls in enumerate(self.disaster_classes)
|
| 657 |
if i < boosted_lstm.shape[1]
|
| 658 |
}
|
| 659 |
result['disaster_confidence'] = round(float(np.max(boosted_lstm[0])), 4)
|
| 660 |
result['disaster_source'] = 'lstm_fallback'
|
| 661 |
else:
|
| 662 |
+
boosted_lstm = self._apply_normal_class_boost(lstm_dis_probs, boost_factor=8.0)
|
| 663 |
idx = int(np.argmax(boosted_lstm[0]))
|
| 664 |
+
result['disaster_prediction'] = self.disaster_classes[idx]
|
| 665 |
result['disaster_probabilities'] = {
|
| 666 |
+
cls: round(float(boosted_lstm[0, i]), 4) for i, cls in enumerate(self.disaster_classes)
|
| 667 |
if i < boosted_lstm.shape[1]
|
| 668 |
}
|
| 669 |
result['disaster_confidence'] = round(float(np.max(boosted_lstm[0])), 4)
|
|
|
|
| 675 |
fusion_wx_scaled = self._scale_fusion(fusion_features, self.fusion_weather_scaler)
|
| 676 |
wx_pred = self.fusion_weather_model.predict(fusion_wx_scaled)
|
| 677 |
wx_proba = self.fusion_weather_model.predict_proba(fusion_wx_scaled)
|
| 678 |
+
result['weather_prediction'] = self.weather_regimes[int(wx_pred[0])] if int(wx_pred[0]) < len(self.weather_regimes) else f'Regime_{wx_pred[0]}'
|
| 679 |
result['weather_probabilities'] = {
|
| 680 |
+
cls: round(float(wx_proba[0, i]), 4) for i, cls in enumerate(self.weather_regimes)
|
| 681 |
if i < wx_proba.shape[1]
|
| 682 |
}
|
| 683 |
result['weather_confidence'] = round(float(np.max(wx_proba[0])), 4)
|
|
|
|
| 685 |
except Exception as e:
|
| 686 |
logger.error(f"[GEOVISION] Weather fusion FAILED: {e}")
|
| 687 |
idx = int(np.argmax(lstm_wx_probs[0]))
|
| 688 |
+
result['weather_prediction'] = self.weather_regimes[idx]
|
| 689 |
result['weather_probabilities'] = {
|
| 690 |
+
cls: round(float(lstm_wx_probs[0, i]), 4) for i, cls in enumerate(self.weather_regimes)
|
| 691 |
if i < lstm_wx_probs.shape[1]
|
| 692 |
}
|
| 693 |
result['weather_confidence'] = round(float(np.max(lstm_wx_probs[0])), 4)
|
| 694 |
result['weather_source'] = 'lstm_fallback'
|
| 695 |
else:
|
| 696 |
idx = int(np.argmax(lstm_wx_probs[0]))
|
| 697 |
+
result['weather_prediction'] = self.weather_regimes[idx]
|
| 698 |
result['weather_probabilities'] = {
|
| 699 |
+
cls: round(float(lstm_wx_probs[0, i]), 4) for i, cls in enumerate(self.weather_regimes)
|
| 700 |
if i < lstm_wx_probs.shape[1]
|
| 701 |
}
|
| 702 |
result['weather_confidence'] = round(float(np.max(lstm_wx_probs[0])), 4)
|
|
|
|
| 781 |
|
| 782 |
intermediate = {
|
| 783 |
'lstm_disaster_probs': {
|
| 784 |
+
cls: round(float(lstm_dis[0, i]), 4) for i, cls in enumerate(self.disaster_classes)
|
| 785 |
if i < lstm_dis.shape[1]
|
| 786 |
},
|
| 787 |
'lstm_weather_probs': {
|
| 788 |
+
cls: round(float(lstm_wx[0, i]), 4) for i, cls in enumerate(self.weather_regimes)
|
| 789 |
if i < lstm_wx.shape[1]
|
| 790 |
},
|
| 791 |
'models_used': models_used,
|
| 792 |
}
|
| 793 |
if ensemble_probs is not None:
|
| 794 |
intermediate['ensemble_disaster_probs'] = {
|
| 795 |
+
cls: round(float(ensemble_probs[0, i]), 4) for i, cls in enumerate(self.disaster_classes)
|
| 796 |
if i < ensemble_probs.shape[1]
|
| 797 |
}
|
| 798 |
|
|
|
|
| 827 |
return {
|
| 828 |
'models_loaded': self.models_loaded,
|
| 829 |
'components': self._load_status,
|
| 830 |
+
'disaster_classes': self.disaster_classes,
|
| 831 |
+
'weather_regimes': self.weather_regimes,
|
| 832 |
'features': {
|
| 833 |
'temporal': len(INPUT_FEATURES),
|
| 834 |
'scalar': len(SCALAR_FEATURE_COLUMNS),
|
server/services/geovision_fusion_service.py
CHANGED
|
@@ -295,13 +295,7 @@ class GeoVisionFusionService:
|
|
| 295 |
# ── Compute dynamic weather regime if model output is degenerate (e.g. Cloudy 100%) ──
|
| 296 |
wx_probs = prediction.get('weather_probabilities', {})
|
| 297 |
if not wx_probs or wx_probs.get('Cloudy', 0) >= 0.99 or max(wx_probs.values(), default=0) >= 0.99:
|
| 298 |
-
|
| 299 |
-
temp = float(np.mean(weather_data.get('temperature_C', [25.0]))) if weather_data.get('temperature_C') else 25.0
|
| 300 |
-
humidity = float(np.mean(weather_data.get('humidity_%', [60.0]))) if weather_data.get('humidity_%') else 60.0
|
| 301 |
-
wind = float(np.mean(weather_data.get('wind_speed_mps', [3.0]))) if weather_data.get('wind_speed_mps') else 3.0
|
| 302 |
-
cloud = float(np.mean(weather_data.get('cloud_amount_%', [30.0]))) if weather_data.get('cloud_amount_%') else 30.0
|
| 303 |
-
|
| 304 |
-
wx_pred, wx_probs_dict, wx_conf = self._compute_dynamic_weather(precip, temp, humidity, wind, cloud)
|
| 305 |
prediction['weather_prediction'] = wx_pred
|
| 306 |
prediction['weather_probabilities'] = wx_probs_dict
|
| 307 |
prediction['weather_confidence'] = wx_conf
|
|
@@ -335,21 +329,35 @@ class GeoVisionFusionService:
|
|
| 335 |
'processing_time_seconds': round(processing_time, 2)
|
| 336 |
}
|
| 337 |
|
| 338 |
-
def _compute_dynamic_weather(self,
|
| 339 |
-
"""Compute realistic weather regime probabilities from physical meteorological
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
|
| 346 |
scores = np.array([score_cloudy, score_dry, score_humid, score_rainy, score_stormy])
|
| 347 |
-
|
|
|
|
| 348 |
|
| 349 |
regimes = ['Cloudy', 'Dry', 'Humid', 'Rainy', 'Stormy']
|
| 350 |
-
probs_dict = {regimes[i]: round(float(
|
| 351 |
-
best_idx = int(np.argmax(
|
| 352 |
-
return regimes[best_idx], probs_dict, round(float(
|
| 353 |
|
| 354 |
# ────────────────────────────────────────────────────────────────
|
| 355 |
# STATUS / HEALTH
|
|
|
|
| 295 |
# ── Compute dynamic weather regime if model output is degenerate (e.g. Cloudy 100%) ──
|
| 296 |
wx_probs = prediction.get('weather_probabilities', {})
|
| 297 |
if not wx_probs or wx_probs.get('Cloudy', 0) >= 0.99 or max(wx_probs.values(), default=0) >= 0.99:
|
| 298 |
+
wx_pred, wx_probs_dict, wx_conf = self._compute_dynamic_weather(weather_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
prediction['weather_prediction'] = wx_pred
|
| 300 |
prediction['weather_probabilities'] = wx_probs_dict
|
| 301 |
prediction['weather_confidence'] = wx_conf
|
|
|
|
| 329 |
'processing_time_seconds': round(processing_time, 2)
|
| 330 |
}
|
| 331 |
|
| 332 |
+
def _compute_dynamic_weather(self, weather_data: Dict[str, Any], T: float = 0.35) -> Tuple[str, Dict[str, float], float]:
|
| 333 |
+
"""Compute realistic weather regime probabilities from physical 60-day meteorological time series."""
|
| 334 |
+
precip_arr = np.array(weather_data.get('precipitation_mm', [0.0]))
|
| 335 |
+
temp_arr = np.array(weather_data.get('temperature_C', [25.0]))
|
| 336 |
+
hum_arr = np.array(weather_data.get('humidity_%', [60.0]))
|
| 337 |
+
wind_arr = np.array(weather_data.get('wind_speed_mps', [3.0]))
|
| 338 |
+
cloud_arr = np.array(weather_data.get('cloud_amount_%', [30.0]))
|
| 339 |
+
|
| 340 |
+
p_7d_mean = np.mean(precip_arr[-7:]) if len(precip_arr) >= 7 else np.mean(precip_arr)
|
| 341 |
+
p_7d_max = np.max(precip_arr[-7:]) if len(precip_arr) >= 7 else np.max(precip_arr)
|
| 342 |
+
t_7d_mean = np.mean(temp_arr[-7:]) if len(temp_arr) >= 7 else np.mean(temp_arr)
|
| 343 |
+
h_7d_mean = np.mean(hum_arr[-7:]) if len(hum_arr) >= 7 else np.mean(hum_arr)
|
| 344 |
+
w_7d_max = np.max(wind_arr[-7:]) if len(wind_arr) >= 7 else np.max(wind_arr)
|
| 345 |
+
c_7d_mean = np.mean(cloud_arr[-7:]) if len(cloud_arr) >= 7 else np.mean(cloud_arr)
|
| 346 |
+
|
| 347 |
+
score_rainy = max(0.05, min(0.95, (p_7d_mean / 10.0) * 0.5 + (p_7d_max / 25.0) * 0.3 + (h_7d_mean / 100.0) * 0.2))
|
| 348 |
+
score_dry = max(0.05, min(0.95, max(0.0, 1.0 - p_7d_mean / 3.0) * 0.5 + max(0.0, (t_7d_mean - 20.0) / 15.0) * 0.3 + max(0.0, 1.0 - h_7d_mean / 75.0) * 0.2))
|
| 349 |
+
score_humid = max(0.05, min(0.95, (h_7d_mean / 100.0) * 0.6 + (t_7d_mean / 35.0) * 0.25 + max(0.0, 1.0 - w_7d_max / 12.0) * 0.15))
|
| 350 |
+
score_stormy = max(0.02, min(0.90, (w_7d_max / 15.0) * 0.55 + (p_7d_max / 30.0) * 0.45))
|
| 351 |
+
score_cloudy = max(0.05, min(0.90, (c_7d_mean / 100.0) * 0.6 + max(0.0, 1.0 - p_7d_mean / 10.0) * 0.25 + (h_7d_mean / 100.0) * 0.15))
|
| 352 |
|
| 353 |
scores = np.array([score_cloudy, score_dry, score_humid, score_rainy, score_stormy])
|
| 354 |
+
exp_scores = np.exp((scores - np.max(scores)) / T)
|
| 355 |
+
probs = exp_scores / np.sum(exp_scores)
|
| 356 |
|
| 357 |
regimes = ['Cloudy', 'Dry', 'Humid', 'Rainy', 'Stormy']
|
| 358 |
+
probs_dict = {regimes[i]: round(float(probs[i]), 4) for i in range(5)}
|
| 359 |
+
best_idx = int(np.argmax(probs))
|
| 360 |
+
return regimes[best_idx], probs_dict, round(float(probs[best_idx]), 4)
|
| 361 |
|
| 362 |
# ────────────────────────────────────────────────────────────────
|
| 363 |
# STATUS / HEALTH
|
server/test_geovision_class_order.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import unittest
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
SERVER_DIR = Path(__file__).resolve().parent
|
| 9 |
+
if str(SERVER_DIR) not in sys.path:
|
| 10 |
+
sys.path.insert(0, str(SERVER_DIR))
|
| 11 |
+
|
| 12 |
+
from models.geovision_fusion_model import GeoVisionFusionModel # noqa: E402
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class FakeProbabilityModel:
|
| 16 |
+
def __init__(self, probabilities):
|
| 17 |
+
self._probabilities = np.array([probabilities], dtype=float)
|
| 18 |
+
|
| 19 |
+
def predict(self, _features):
|
| 20 |
+
return np.array([int(np.argmax(self._probabilities[0]))])
|
| 21 |
+
|
| 22 |
+
def predict_proba(self, _features):
|
| 23 |
+
return self._probabilities
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class FakeScaler:
|
| 27 |
+
n_features_in_ = 138
|
| 28 |
+
|
| 29 |
+
def transform(self, features):
|
| 30 |
+
if features.shape[1] != self.n_features_in_:
|
| 31 |
+
raise AssertionError(f"expected 138 features, got {features.shape[1]}")
|
| 32 |
+
return features
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class GeoVisionClassOrderTest(unittest.TestCase):
|
| 36 |
+
def setUp(self):
|
| 37 |
+
self.model = GeoVisionFusionModel()
|
| 38 |
+
self.lstm_disaster = np.array([[0.9, 0.05, 0.02, 0.02, 0.01]])
|
| 39 |
+
self.lstm_weather = np.array([[0.8, 0.1, 0.05, 0.03, 0.02]])
|
| 40 |
+
self.embeddings = np.zeros((1, 128))
|
| 41 |
+
|
| 42 |
+
def test_fusion_prediction_uses_saved_disaster_class_order(self):
|
| 43 |
+
self.model.fusion_disaster_model = FakeProbabilityModel([0.91, 0.04, 0.03, 0.01, 0.01])
|
| 44 |
+
|
| 45 |
+
result = self.model._stack_and_predict(
|
| 46 |
+
self.lstm_disaster,
|
| 47 |
+
self.lstm_weather,
|
| 48 |
+
self.embeddings,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
self.assertEqual(result["disaster_prediction"], "Normal")
|
| 52 |
+
self.assertEqual(result["disaster_probabilities"]["Normal"], 0.91)
|
| 53 |
+
|
| 54 |
+
def test_fusion_prediction_uses_saved_weather_regime_order(self):
|
| 55 |
+
self.model.fusion_weather_model = FakeProbabilityModel([0.82, 0.08, 0.05, 0.03, 0.02])
|
| 56 |
+
|
| 57 |
+
result = self.model._stack_and_predict(
|
| 58 |
+
self.lstm_disaster,
|
| 59 |
+
self.lstm_weather,
|
| 60 |
+
self.embeddings,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
self.assertEqual(result["weather_prediction"], "Rainy")
|
| 64 |
+
self.assertEqual(result["weather_probabilities"]["Rainy"], 0.82)
|
| 65 |
+
|
| 66 |
+
def test_fusion_prediction_does_not_fabricate_normal_confidence(self):
|
| 67 |
+
self.model.fusion_disaster_model = FakeProbabilityModel([0.3, 0.25, 0.2, 0.15, 0.1])
|
| 68 |
+
|
| 69 |
+
result = self.model._stack_and_predict(
|
| 70 |
+
self.lstm_disaster,
|
| 71 |
+
self.lstm_weather,
|
| 72 |
+
self.embeddings,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
self.assertEqual(result["disaster_prediction"], "Normal")
|
| 76 |
+
self.assertEqual(result["disaster_probabilities"]["Normal"], 0.3)
|
| 77 |
+
|
| 78 |
+
def test_weak_hazard_prediction_is_calibrated_to_normal(self):
|
| 79 |
+
self.model.fusion_disaster_model = FakeProbabilityModel([0.05, 0.34, 0.26, 0.2, 0.15])
|
| 80 |
+
|
| 81 |
+
result = self.model._stack_and_predict(
|
| 82 |
+
self.lstm_disaster,
|
| 83 |
+
self.lstm_weather,
|
| 84 |
+
self.embeddings,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
self.assertEqual(result["disaster_prediction"], "Normal")
|
| 88 |
+
self.assertGreater(result["disaster_probabilities"]["Normal"], 0.58)
|
| 89 |
+
self.assertLess(result["disaster_probabilities"]["Normal"], 0.86)
|
| 90 |
+
|
| 91 |
+
def test_strong_hazard_prediction_breaks_through_normal_calibration(self):
|
| 92 |
+
self.model.fusion_disaster_model = FakeProbabilityModel([0.05, 0.72, 0.1, 0.08, 0.05])
|
| 93 |
+
|
| 94 |
+
result = self.model._stack_and_predict(
|
| 95 |
+
self.lstm_disaster,
|
| 96 |
+
self.lstm_weather,
|
| 97 |
+
self.embeddings,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
self.assertEqual(result["disaster_prediction"], "Flood")
|
| 101 |
+
self.assertEqual(result["disaster_probabilities"]["Flood"], 0.72)
|
| 102 |
+
|
| 103 |
+
def test_fusion_vector_matches_trained_feature_count(self):
|
| 104 |
+
self.model.fusion_disaster_model = FakeProbabilityModel([0.3, 0.25, 0.2, 0.15, 0.1])
|
| 105 |
+
self.model.fusion_disaster_scaler = FakeScaler()
|
| 106 |
+
|
| 107 |
+
result = self.model._stack_and_predict(
|
| 108 |
+
self.lstm_disaster,
|
| 109 |
+
self.lstm_weather,
|
| 110 |
+
self.embeddings,
|
| 111 |
+
ensemble_probs=np.zeros((1, 5)),
|
| 112 |
+
cnn_probs=np.zeros((1, 5)),
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
self.assertEqual(result["fusion_features_dim"], 138)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
unittest.main()
|