Spaces:
Running
Running
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 +6 -6
- configs/default.yaml +1 -0
- scripts/export_embeddings.py +8 -0
- scripts/precompute_text_embeddings.py +10 -0
- src/serving/api.py +4 -0
- src/serving/helpers.py +56 -3
- src/serving/inference_engine.py +47 -6
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|