ydmhmhm commited on
Commit
f52e6fb
·
1 Parent(s): 1c3f05c

feat: add scholarship metadata export and fit scores to API

Browse files

- Add scholarship_metadata.npy output alongside embeddings in config
- Export metadata during precompute_text_embeddings.py using _build_scholarship_metadata helper
- Save metadata during export_embeddings.py for serving pipeline
- Add fit_scores field to ScholarshipResult model with academic, leadership, and language alignment scores
- Update README docs with python -m script invocation examples

README.md CHANGED
@@ -113,21 +113,21 @@ python scripts/precompute_text_embeddings.py # or python -m scripts.precompute_t
113
  python scripts/train.py --config configs/default.yaml # or python -m scripts.train --config configs/default.yaml
114
 
115
  # Step 3 — Evaluasi pada test set (checkpoint paths default to configs/default.yaml)
116
- python scripts/evaluate.py \
117
  --config configs/default.yaml
118
 
119
- # Override checkpoint paths if needed:
120
- python scripts/evaluate.py \
121
  --config configs/default.yaml \
122
  --student_checkpoint outputs/checkpoints/student_tower_best.keras \
123
  --scholarship_checkpoint outputs/checkpoints/scholarship_tower_best.keras
124
 
125
  # Step 4 — Export scholarship embeddings untuk serving (checkpoint path defaults to config)
126
- python scripts/export_embeddings.py \
127
  --config configs/default.yaml
128
 
129
- # Override checkpoint path if needed:
130
- python scripts/export_embeddings.py \
131
  --scholarship_checkpoint outputs/checkpoints/scholarship_tower_best.keras
132
  ```
133
 
 
113
  python scripts/train.py --config configs/default.yaml # or python -m scripts.train --config configs/default.yaml
114
 
115
  # Step 3 — Evaluasi pada test set (checkpoint paths default to configs/default.yaml)
116
+ python scripts/evaluate.py \ # or python -m scripts.evaluate \
117
  --config configs/default.yaml
118
 
119
+ # or for Step 3 - Override checkpoint paths if needed:
120
+ python scripts/evaluate.py \ # or python -m scripts.evaluate \
121
  --config configs/default.yaml \
122
  --student_checkpoint outputs/checkpoints/student_tower_best.keras \
123
  --scholarship_checkpoint outputs/checkpoints/scholarship_tower_best.keras
124
 
125
  # Step 4 — Export scholarship embeddings untuk serving (checkpoint path defaults to config)
126
+ python scripts/export_embeddings.py \ # or python -m scripts.export_embeddings \
127
  --config configs/default.yaml
128
 
129
+ # or for Step 4 - Override checkpoint path if needed:
130
+ python scripts/export_embeddings.py \ # or python -m scripts.export_embeddings \
131
  --scholarship_checkpoint outputs/checkpoints/scholarship_tower_best.keras
132
  ```
133
 
configs/default.yaml CHANGED
@@ -68,6 +68,7 @@ hf:
68
  embeddings:
69
  scholarship_emb: outputs/embeddings/scholarship_emb.npy
70
  scholarship_ids: outputs/embeddings/scholarship_ids.npy
 
71
 
72
  tensorboard:
73
  enabled: true
 
68
  embeddings:
69
  scholarship_emb: outputs/embeddings/scholarship_emb.npy
70
  scholarship_ids: outputs/embeddings/scholarship_ids.npy
71
+ scholarship_metadata: outputs/embeddings/scholarship_metadata.npy
72
 
73
  tensorboard:
74
  enabled: true
scripts/export_embeddings.py CHANGED
@@ -14,9 +14,11 @@ import os
14
  import numpy as np
15
  import yaml
16
  import tensorflow as tf
 
17
 
18
  from src.models.student_tower import L2Normalize
19
  from src.utils.data_loader import load_precomputed_features
 
20
 
21
 
22
  def parse_args():
@@ -65,8 +67,14 @@ def main():
65
  np.save(cfg["embeddings"]["scholarship_emb"], sch_emb)
66
  np.save(cfg["embeddings"]["scholarship_ids"], np.array(sch_ids, dtype=object))
67
 
 
 
 
 
 
68
  print(f"\nSaved: {cfg['embeddings']['scholarship_emb']}")
69
  print(f"Saved: {cfg['embeddings']['scholarship_ids']}")
 
70
 
71
 
72
  if __name__ == "__main__":
 
14
  import numpy as np
15
  import yaml
16
  import tensorflow as tf
17
+ import pandas as pd
18
 
19
  from src.models.student_tower import L2Normalize
20
  from src.utils.data_loader import load_precomputed_features
21
+ from src.serving.helpers import _build_scholarship_metadata
22
 
23
 
24
  def parse_args():
 
67
  np.save(cfg["embeddings"]["scholarship_emb"], sch_emb)
68
  np.save(cfg["embeddings"]["scholarship_ids"], np.array(sch_ids, dtype=object))
69
 
70
+ # Build and save metadata alongside embeddings
71
+ scholarships_df = pd.read_csv(f"{cfg['data']['raw_path']}/scholarships.csv")
72
+ metadata = _build_scholarship_metadata(scholarships_df)
73
+ np.save(cfg["embeddings"]["scholarship_metadata"], np.array(metadata, dtype=object))
74
+
75
  print(f"\nSaved: {cfg['embeddings']['scholarship_emb']}")
76
  print(f"Saved: {cfg['embeddings']['scholarship_ids']}")
77
+ print(f"Saved: {cfg['embeddings']['scholarship_metadata']}")
78
 
79
 
80
  if __name__ == "__main__":
scripts/precompute_text_embeddings.py CHANGED
@@ -13,8 +13,10 @@ import argparse
13
  import os
14
  import numpy as np
15
  import pandas as pd
 
16
 
17
  from src.utils.feature_engineering import encode_text
 
18
 
19
 
20
  def parse_args():
@@ -88,9 +90,17 @@ def main():
88
  np.save(SCH_IDS_PATH, np.array(scholarship_ids, dtype=object))
89
  print(f"Saved: {SCH_IDS_PATH} ({len(scholarship_ids)} IDs)")
90
 
 
 
 
 
 
 
91
  print("\nDone. Shapes:")
92
  print(f" students.npy : {student_text_emb.shape}")
93
  print(f" scholarships.npy: {scholarship_text_emb.shape}")
 
 
94
 
95
 
96
  if __name__ == "__main__":
 
13
  import os
14
  import numpy as np
15
  import pandas as pd
16
+ import yaml
17
 
18
  from src.utils.feature_engineering import encode_text
19
+ from src.serving.helpers import _build_scholarship_metadata
20
 
21
 
22
  def parse_args():
 
90
  np.save(SCH_IDS_PATH, np.array(scholarship_ids, dtype=object))
91
  print(f"Saved: {SCH_IDS_PATH} ({len(scholarship_ids)} IDs)")
92
 
93
+ # ── Scholarship Metadata ────────────────────────────────────────────────
94
+ SCH_META_PATH = cfg["embeddings"]["scholarship_metadata"]
95
+ metadata = _build_scholarship_metadata(scholarships_df)
96
+ np.save(SCH_META_PATH, np.array(metadata, dtype=object))
97
+ print(f"Saved: {SCH_META_PATH} ({len(metadata)} entries)")
98
+
99
  print("\nDone. Shapes:")
100
  print(f" students.npy : {student_text_emb.shape}")
101
  print(f" scholarships.npy: {scholarship_text_emb.shape}")
102
+ print(f" scholarship_metadata.npy: {len(metadata)} entries")
103
+
104
 
105
 
106
  if __name__ == "__main__":
src/serving/api.py CHANGED
@@ -201,6 +201,10 @@ class ScholarshipResult(BaseModel):
201
  description="Personalized recommendation explaining why this scholarship is a match",
202
  )
203
  metadata: dict
 
 
 
 
204
 
205
 
206
  class RecommendationResponse(BaseModel):
 
201
  description="Personalized recommendation explaining why this scholarship is a match",
202
  )
203
  metadata: dict
204
+ fit_scores: Optional[dict] = Field(
205
+ default=None,
206
+ description="Fit scores for academic, leadership, and language alignment (0-1 scale)",
207
+ )
208
 
209
 
210
  class RecommendationResponse(BaseModel):
src/serving/helpers.py CHANGED
@@ -57,23 +57,76 @@ def _parse_csv_with_json(csv_text: str, json_cols: list[str]) -> pd.DataFrame:
57
 
58
 
59
  def _build_scholarship_metadata(df) -> list[dict]:
60
- """Extract lightweight metadata from scholarship DataFrame for API responses."""
 
 
 
 
 
 
61
  metadata = []
62
  for _, row in df.iterrows():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  metadata.append({
64
  "scholarship_id": row.get("scholarship_id"),
 
 
 
65
  "host_country": row.get("host_country"),
66
  "host_region": row.get("host_region"),
67
  "funding_is_full_funding": bool(row.get("funding_is_full_funding", False)),
 
68
  })
69
  return metadata
70
 
71
 
72
  def _scholarship_to_metadata(sch: dict) -> dict:
73
- """Convert scholarship dict to lightweight metadata for API responses."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  return {
75
  "scholarship_id": sch.get("scholarship_id"),
 
 
 
76
  "host_country": sch.get("host_country"),
77
  "host_region": sch.get("host_region"),
78
  "funding_is_full_funding": bool(sch.get("funding_is_full_funding", False)),
79
- }
 
 
57
 
58
 
59
  def _build_scholarship_metadata(df) -> list[dict]:
60
+ """Extract enriched metadata from scholarship DataFrame for API responses.
61
+
62
+ Includes fields useful for LLM-based recommendation generation:
63
+ - Core identity (id, name)
64
+ - Context (mission_statement, selection_criteria)
65
+ - Location & funding info
66
+ """
67
  metadata = []
68
  for _, row in df.iterrows():
69
+ # Build human-readable funding summary from individual boolean fields
70
+ funding_parts = []
71
+ if row.get("funding_covers_tuition"):
72
+ funding_parts.append("tuition")
73
+ if row.get("funding_covers_living"):
74
+ funding_parts.append("living expenses")
75
+ if row.get("funding_covers_airfare"):
76
+ funding_parts.append("airfare")
77
+ if row.get("funding_covers_insurance"):
78
+ funding_parts.append("insurance")
79
+ if funding_parts:
80
+ funding_summary = f"Covers {', '.join(funding_parts)}"
81
+ else:
82
+ funding_summary = "No specified funding coverage"
83
+
84
+ # Add monthly stipend info if available
85
+ stipend = row.get("funding_monthly_stipend")
86
+ if stipend and float(stipend) > 0:
87
+ funding_summary += f", plus ${float(stipend):,.0f}/month stipend"
88
+
89
  metadata.append({
90
  "scholarship_id": row.get("scholarship_id"),
91
+ "name": row.get("name"),
92
+ "mission_statement": row.get("mission_statement"),
93
+ "selection_criteria": row.get("selection_criteria"),
94
  "host_country": row.get("host_country"),
95
  "host_region": row.get("host_region"),
96
  "funding_is_full_funding": bool(row.get("funding_is_full_funding", False)),
97
+ "funding_coverage_summary": funding_summary,
98
  })
99
  return metadata
100
 
101
 
102
  def _scholarship_to_metadata(sch: dict) -> dict:
103
+ """Convert scholarship dict to enriched metadata for API responses."""
104
+ # Build human-readable funding summary
105
+ funding_parts = []
106
+ if sch.get("funding_covers_tuition"):
107
+ funding_parts.append("tuition")
108
+ if sch.get("funding_covers_living"):
109
+ funding_parts.append("living expenses")
110
+ if sch.get("funding_covers_airfare"):
111
+ funding_parts.append("airfare")
112
+ if sch.get("funding_covers_insurance"):
113
+ funding_parts.append("insurance")
114
+ if funding_parts:
115
+ funding_summary = f"Covers {', '.join(funding_parts)}"
116
+ else:
117
+ funding_summary = "No specified funding coverage"
118
+
119
+ stipend = sch.get("funding_monthly_stipend")
120
+ if stipend and float(stipend) > 0:
121
+ funding_summary += f", plus ${float(stipend):,.0f}/month stipend"
122
+
123
  return {
124
  "scholarship_id": sch.get("scholarship_id"),
125
+ "name": sch.get("name"),
126
+ "mission_statement": sch.get("mission_statement"),
127
+ "selection_criteria": sch.get("selection_criteria"),
128
  "host_country": sch.get("host_country"),
129
  "host_region": sch.get("host_region"),
130
  "funding_is_full_funding": bool(sch.get("funding_is_full_funding", False)),
131
+ "funding_coverage_summary": funding_summary,
132
+ }
src/serving/inference_engine.py CHANGED
@@ -541,6 +541,7 @@ class InferenceEngine:
541
  """
542
  sch_emb_path = self.cfg["embeddings"]["scholarship_emb"]
543
  sch_ids_path = self.cfg["embeddings"]["scholarship_ids"]
 
544
 
545
  if not (os.path.exists(sch_emb_path) and os.path.exists(sch_ids_path)):
546
  _print(" No cached embeddings found — will recompute from CSVs.")
@@ -549,18 +550,48 @@ class InferenceEngine:
549
  try:
550
  self._sch_emb = np.load(sch_emb_path, allow_pickle=False)
551
  self._sch_ids = list(np.load(sch_ids_path, allow_pickle=True).tolist())
552
- self._sch_metadata = [] # Will be built lazily from CSV if needed
553
 
554
- # Build metadata from the saved IDs (minimal just enough for API)
555
- self._sch_metadata = [{"scholarship_id": sid} for sid in self._sch_ids]
 
 
 
 
 
 
 
 
 
 
 
 
 
556
 
557
- _print(f"Loaded cached embeddings: {len(self._sch_emb)} scholarships "
558
- f"(shape {self._sch_emb.shape})")
559
  return True
560
  except Exception as e:
561
  print(f" Failed to load cached embeddings: {e}", file=sys.stderr, flush=True)
562
  return False
563
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  def initialize(self):
565
  """Load both towers and build initial scholarship embedding cache.
566
 
@@ -649,10 +680,20 @@ class InferenceEngine:
649
  sch_feat, training=False
650
  ).numpy() # (N, 128) L2-normalized
651
 
652
- # Build metadata for API responses
653
  self._sch_ids = scholarships_df["scholarship_id"].tolist()
654
  self._sch_metadata = _build_scholarship_metadata(scholarships_df)
655
 
 
 
 
 
 
 
 
 
 
 
656
  print(f"Scholarship cache refreshed: {len(self._sch_ids)} scholarships "
657
  f"(embedding shape {self._sch_emb.shape})")
658
 
 
541
  """
542
  sch_emb_path = self.cfg["embeddings"]["scholarship_emb"]
543
  sch_ids_path = self.cfg["embeddings"]["scholarship_ids"]
544
+ sch_meta_path = self.cfg["embeddings"]["scholarship_metadata"]
545
 
546
  if not (os.path.exists(sch_emb_path) and os.path.exists(sch_ids_path)):
547
  _print(" No cached embeddings found — will recompute from CSVs.")
 
550
  try:
551
  self._sch_emb = np.load(sch_emb_path, allow_pickle=False)
552
  self._sch_ids = list(np.load(sch_ids_path, allow_pickle=True).tolist())
 
553
 
554
+ # Load enriched metadata (name, mission_statement, selection_criteria, etc.)
555
+ if os.path.exists(sch_meta_path):
556
+ self._sch_metadata = list(
557
+ np.load(sch_meta_path, allow_pickle=True).tolist()
558
+ )
559
+ _print(f"Loaded cached embeddings: {len(self._sch_emb)} scholarships "
560
+ f"(shape {self._sch_emb.shape}, metadata: {len(self._sch_metadata)} entries)")
561
+
562
+ # Normalize JSON columns in metadata to ensure consistency with /refresh path
563
+ self._sch_metadata = self._normalize_cached_metadata()
564
+ else:
565
+ # Fallback: build minimal metadata from saved IDs if metadata file missing
566
+ self._sch_metadata = [{"scholarship_id": sid} for sid in self._sch_ids]
567
+ _print(f"Loaded cached embeddings: {len(self._sch_emb)} scholarships "
568
+ f"(shape {self._sch_emb.shape}, minimal metadata — metadata.npy not found)")
569
 
 
 
570
  return True
571
  except Exception as e:
572
  print(f" Failed to load cached embeddings: {e}", file=sys.stderr, flush=True)
573
  return False
574
 
575
+ def _normalize_cached_metadata(self) -> list[dict]:
576
+ """Normalize JSON columns in cached metadata to match /refresh output.
577
+
578
+ Reconstructs a DataFrame from the cached IDs, loads fresh CSV data,
579
+ normalizes JSON columns (selection_criteria, language_requirements),
580
+ and rebuilds metadata using _build_scholarship_metadata().
581
+ This ensures consistency between cold-boot and /refresh paths.
582
+ """
583
+ # Load fresh scholarships CSV to get properly parsed JSON columns
584
+ raw_path = self.cfg["data"]["raw_path"]
585
+ try:
586
+ scholarships_df = pd.read_csv(f"{raw_path}/scholarships.csv")
587
+ scholarships_df = self._normalize_json_columns(scholarships_df, SCHOLARSHIP_JSON_COLS)
588
+
589
+ # Rebuild metadata from normalized data
590
+ return _build_scholarship_metadata(scholarships_df)
591
+ except Exception:
592
+ # If we can't normalize, return original metadata as-is
593
+ return self._sch_metadata
594
+
595
  def initialize(self):
596
  """Load both towers and build initial scholarship embedding cache.
597
 
 
680
  sch_feat, training=False
681
  ).numpy() # (N, 128) L2-normalized
682
 
683
+ # Build metadata for API responses and persist to disk
684
  self._sch_ids = scholarships_df["scholarship_id"].tolist()
685
  self._sch_metadata = _build_scholarship_metadata(scholarships_df)
686
 
687
+ # Persist metadata to disk so subsequent cold boots have enriched data
688
+ sch_meta_path = self.cfg["embeddings"]["scholarship_metadata"]
689
+ np.save(sch_meta_path, np.array(self._sch_metadata, dtype=object))
690
+
691
+ # Persist embeddings + IDs to disk (so they survive server restart)
692
+ sch_emb_path = self.cfg["embeddings"]["scholarship_emb"]
693
+ sch_ids_path = self.cfg["embeddings"]["scholarship_ids"]
694
+ np.save(sch_emb_path, self._sch_emb)
695
+ np.save(sch_ids_path, np.array(self._sch_ids, dtype=object))
696
+
697
  print(f"Scholarship cache refreshed: {len(self._sch_ids)} scholarships "
698
  f"(embedding shape {self._sch_emb.shape})")
699