cropintel / ml /scripts /overnight_orchestrator.py
Jaithra Polavarapu
CropIntel — HF Space deploy (all-in-one app)
889dd1b
Raw
History Blame Contribute Delete
18.5 kB
#!/usr/bin/env python3
"""
Overnight autonomous training orchestrator for CropIntel.
Trains all 4 crops sequentially with automatic retry logic,
architecture switching, disk management, and comprehensive logging.
Usage:
python -m ml.scripts.overnight_orchestrator
"""
import json
import os
import shutil
import signal
import subprocess
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path
# Must be before any TF imports
os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "2")
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from ml.config import CROPS, DATA_DIR, MODELS_DIR, TRAINING_CONFIG # noqa: E402
from ml.training.train_crop import train_crop_model # noqa: E402
from ml.scripts.sanity_check import sanity_check_crop # noqa: E402
# Item 10: gate every full run behind a 3-epoch sanity check. A crop whose
# predictions collapse to a single class is skipped (logged) rather than burning
# 50 epochs. Toggle with RUN_SANITY=0; SANITY_ONLY=1 stops after the checks.
RUN_SANITY = os.environ.get("RUN_SANITY", "1") != "0"
SANITY_ONLY = os.environ.get("SANITY_ONLY", "0") == "1"
SANITY_EPOCHS = int(os.environ.get("SANITY_EPOCHS", "3"))
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
EPOCHS = 50
VAL_ACCURACY_THRESHOLD = 0.60
MIN_DISK_GB = 5.0
# Retry sequence: (architecture, phase2_lr)
# Phase 2 uses a FIXED 1e-4 LR (item 6) — no ReduceLROnPlateau. Fallback
# architectures keep the same LR; only the backbone changes.
RETRY_SEQUENCE = [
("EfficientNetB0", 1e-4),
("MobileNetV2", 1e-4),
("ResNet50V2", 1e-4),
]
LOG_DIR = ROOT / "ml" / "logs"
RUN_LOG = LOG_DIR / "overnight_run.log"
SUMMARY = LOG_DIR / "overnight_summary.txt"
ZIP_OUT = ROOT / "cropintel-models.zip"
LOG_DIR.mkdir(parents=True, exist_ok=True)
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
_log_file = open(RUN_LOG, "a", buffering=1)
def log(msg: str, level: str = "INFO") -> None:
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] [{level}] {msg}"
print(line, flush=True)
_log_file.write(line + "\n")
# ---------------------------------------------------------------------------
# Disk management
# ---------------------------------------------------------------------------
def free_gb() -> float:
return shutil.disk_usage("/").free / (1024 ** 3)
def ensure_disk_space() -> float:
gb = free_gb()
log(f"Disk free: {gb:.1f} GB")
if gb < MIN_DISK_GB:
log("Low disk — removing extracted zip files ...", "WARN")
for zp in (ROOT / "ml" / "data").rglob("*.zip"):
size_gb = zp.stat().st_size / (1024 ** 3)
log(f" Deleting {zp.name} ({size_gb:.2f} GB)")
zp.unlink()
gb = free_gb()
log(f"Disk after cleanup: {gb:.1f} GB")
if gb < MIN_DISK_GB:
log(f"Still below {MIN_DISK_GB} GB — training may hit OOM.", "WARN")
return gb
# ---------------------------------------------------------------------------
# Data verification / pre-training setup
# ---------------------------------------------------------------------------
def count_images(folder: Path) -> int:
if not folder.is_dir():
return 0
exts = ("*.jpg", "*.JPG", "*.jpeg", "*.JPEG", "*.png", "*.PNG")
return sum(len(list(folder.rglob(e))) for e in exts)
def verify_crop_data(crop: str) -> bool:
base = DATA_DIR / crop
total = count_images(base)
log(f" {crop}: {total} images in {base}")
if total == 0:
log(f" {crop}: NO DATA FOUND — skipping", "ERROR")
return False
return True
def pre_training_setup(crop: str) -> None:
"""Pre-training data preparation steps per crop."""
if crop == "soybean":
# Ensure supplemental Healthy images are available via standard path.
sup_healthy = DATA_DIR / "soybean" / "supplemental" / "Healthy"
if sup_healthy.is_dir():
n = count_images(sup_healthy)
log(f" soybean: supplemental/Healthy has {n} images — OK")
else:
log(" soybean: supplemental/Healthy not found — will rely on base dataset", "WARN")
if crop == "rice":
for d in [
DATA_DIR / "rice" / "Rice_Leaf_AUG",
DATA_DIR / "rice" / "supplemental" / "LabelledRice",
DATA_DIR / "rice" / "supplemental" / "RiceDiseaseDataset",
]:
if d.is_dir():
log(f" rice: found {d.name} ({count_images(d)} images)")
if crop == "wheat":
sup = DATA_DIR / "wheat" / "supplemental"
if sup.is_dir():
log(f" wheat: supplemental has {count_images(sup)} images")
else:
log(" wheat: no supplemental data — using base dataset only", "WARN")
# ---------------------------------------------------------------------------
# Latest metrics helper
# ---------------------------------------------------------------------------
def latest_metrics(crop: str):
"""Return (val_accuracy, version_name, metrics_dict) from the most recent metrics.json."""
crop_dir = MODELS_DIR / crop
if not crop_dir.is_dir():
return None, None, {}
versions = sorted(
[v for v in crop_dir.iterdir() if v.is_dir()],
key=lambda p: p.stat().st_mtime,
reverse=True,
)
for v in versions:
mp = v / "metrics.json"
if mp.exists():
try:
m = json.loads(mp.read_text())
return m.get("accuracy", 0.0), v.name, m
except Exception:
continue
return None, None, {}
# ---------------------------------------------------------------------------
# Single training attempt
# ---------------------------------------------------------------------------
def attempt(crop: str, arch: str, lr: float, batch_size: int) -> float | str:
"""
Run one training attempt.
Returns val_accuracy (float) or an error tag string.
"""
log(f" → attempt: {crop} | arch={arch} | phase2_lr={lr} | batch={batch_size}")
try:
import tensorflow as tf # already imported but re-resolves cleanly
model_dir = train_crop_model(
crop=crop,
epochs=EPOCHS,
fine_tune=True,
from_scratch=False,
architecture=arch,
phase2_lr=lr,
batch_size=batch_size,
)
mp = model_dir / "metrics.json"
if not mp.exists():
log(f" metrics.json missing after training {crop}", "ERROR")
return "NO_METRICS"
m = json.loads(mp.read_text())
acc = m.get("accuracy", 0.0)
log(f" ✓ {crop}/{arch} finished — test_accuracy={acc:.4f}")
return acc
except Exception as exc:
msg = str(exc)
tb = traceback.format_exc()
log(f" ✗ {crop}/{arch} raised {type(exc).__name__}: {msg}", "ERROR")
_log_file.write(tb + "\n")
# OOM detection
oom_signals = ("ResourceExhausted", "OOM", "out of memory",
"cannot allocate", "RESOURCE_EXHAUSTED")
if any(s.lower() in msg.lower() for s in oom_signals) or any(
s.lower() in tb.lower() for s in oom_signals
):
return "OOM"
if isinstance(exc, FileNotFoundError):
return "FILENOTFOUND"
return "ERROR"
# ---------------------------------------------------------------------------
# Per-crop orchestration
# ---------------------------------------------------------------------------
results: dict = {}
sanity_results: dict = {}
def run_crop(crop: str) -> None:
log(f"\n{'='*60}")
log(f"CROP: {crop.upper()}")
log(f"{'='*60}")
ensure_disk_space()
if not verify_crop_data(crop):
results[crop] = {"status": "skipped_no_data", "architecture": None,
"val_accuracy": None, "retries": 0}
return
pre_training_setup(crop)
# Item 10: 3-epoch sanity check; skip the crop entirely if it mode-collapses.
if RUN_SANITY:
log(f" Running {SANITY_EPOCHS}-epoch sanity check for {crop} ...")
try:
sr = sanity_check_crop(crop, epochs=SANITY_EPOCHS)
except Exception as e:
sr = {"status": "ERROR", "error": str(e), "collapsed": None,
"val_accuracy": None}
log(f" Sanity check raised {type(e).__name__}: {e}", "ERROR")
sanity_results[crop] = sr
log(f" Sanity: status={sr.get('status')} "
f"val_acc={sr.get('val_accuracy')} collapsed={sr.get('collapsed')}")
if sr.get("collapsed"):
log(f" SKIPPING {crop} — sanity check shows mode collapse "
f"(dominant pred {sr.get('dominant_pred_share')}).", "WARN")
results[crop] = {"status": "skipped_sanity_collapse", "architecture": None,
"val_accuracy": sr.get("val_accuracy"), "retries": 0}
return
if SANITY_ONLY:
log(f" SANITY_ONLY set — skipping full training for {crop}.")
results[crop] = {"status": "sanity_only", "architecture": None,
"val_accuracy": (sanity_results.get(crop) or {}).get("val_accuracy"),
"retries": 0}
return
best_acc = 0.0
best_arch = None
batch_size = 32
retries = 0
for arch, lr in RETRY_SEQUENCE:
log(f"\n Attempt {retries + 1}/4 — {arch}, lr={lr}, batch={batch_size}")
result = attempt(crop, arch, lr, batch_size)
# OOM: halve batch and retry same attempt (once)
if result == "OOM":
log(f" OOM detected — halving batch size to {batch_size // 2} and retrying")
batch_size = max(8, batch_size // 2)
result = attempt(crop, arch, lr, batch_size)
if isinstance(result, str):
# Non-recoverable error for this attempt
log(f" Attempt failed ({result}), moving to next", "WARN")
retries += 1
continue
# result is a float accuracy
if result > best_acc:
best_acc = result
best_arch = arch
if result >= VAL_ACCURACY_THRESHOLD:
log(f" ✓ THRESHOLD MET: {crop}/{arch} val_accuracy={result:.4f}")
results[crop] = {
"status": "success",
"architecture": arch,
"val_accuracy": result,
"retries": retries,
"phase2_lr": lr,
}
return
log(f" Below threshold ({result:.4f} < {VAL_ACCURACY_THRESHOLD}) — trying next")
retries += 1
# All attempts exhausted
status = "best_below_threshold" if best_acc > 0 else "all_failed"
log(f" All attempts done. Best: {best_arch} @ {best_acc:.4f} — status={status}", "WARN")
results[crop] = {
"status": status,
"architecture": best_arch,
"val_accuracy": best_acc if best_acc > 0 else None,
"retries": retries,
"phase2_lr": None,
}
# ---------------------------------------------------------------------------
# Packaging
# ---------------------------------------------------------------------------
def package_models() -> bool:
log(f"\n{'='*60}")
log("PACKAGING MODELS")
log(f"{'='*60}")
try:
r = subprocess.run(
[sys.executable, "-m", "ml.scripts.package_models", "-o", str(ZIP_OUT)],
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=300,
)
if ZIP_OUT.exists() and ZIP_OUT.stat().st_size > 1024 * 1024:
log(f" Package OK: {ZIP_OUT} ({ZIP_OUT.stat().st_size / (1024**2):.1f} MB)")
return True
log(f" Package may be missing/small. stdout={r.stdout!r} stderr={r.stderr!r}", "WARN")
return False
except Exception as e:
log(f" Packaging error: {e}", "ERROR")
return False
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
def write_summary(pkg_ok: bool) -> None:
gb = free_gb()
lines = [
"=" * 60,
"OVERNIGHT TRAINING SUMMARY",
f"Completed: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"=" * 60,
"",
]
for crop, r in results.items():
acc_str = f"{r['val_accuracy']:.4f}" if r["val_accuracy"] is not None else "N/A"
lines += [
f"{crop.upper()}:",
f" Status : {r['status']}",
f" Architecture: {r['architecture']}",
f" Val Accuracy: {acc_str}",
f" Retries : {r['retries']}",
]
_, ver, m = latest_metrics(crop)
if ver and m:
lines.append(" Per-class F1:")
for cls, cm in m.get("per_class", {}).items():
lines.append(
f" {cls:30s} p={cm['precision']:.3f} r={cm['recall']:.3f} f1={cm['f1_score']:.3f}"
)
cm_raw = m.get("confusion_matrix")
if cm_raw:
lines.append(" Confusion matrix:")
for row in cm_raw:
lines.append(f" {row}")
lines.append("")
lines += [
f"Disk space remaining : {gb:.1f} GB",
f"Model package : {ZIP_OUT} ({'OK' if pkg_ok else 'MISSING/FAILED'})",
"",
]
text = "\n".join(lines)
print(text, flush=True)
SUMMARY.write_text(text)
log(f"Summary written to {SUMMARY}")
# ---------------------------------------------------------------------------
# Training summary (item 15) — concise, decision-oriented
# ---------------------------------------------------------------------------
TRAIN_SUMMARY = LOG_DIR / "training_summary.txt"
def write_training_summary(pkg_ok: bool) -> None:
lines = [
"=" * 60,
"CROPINTEL TRAINING SUMMARY",
f"Completed: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"=" * 60,
"",
"FINAL VAL ACCURACY PER CROP:",
]
for crop, r in results.items():
acc = f"{r['val_accuracy']:.4f}" if r.get("val_accuracy") is not None else "N/A"
lines.append(f" {crop:<10} {acc:>8} ({r['status']}, arch={r.get('architecture')})")
lines += ["", "UNDERPERFORMING CLASSES (recall < 0.6):"]
any_weak = False
for crop in results:
_, ver, m = latest_metrics(crop)
if not (ver and m):
continue
for cls, cm in m.get("per_class", {}).items():
if cm.get("recall", 1.0) < 0.6:
any_weak = True
lines.append(f" {crop}/{cls:<28} recall={cm['recall']:.3f} "
f"precision={cm['precision']:.3f} f1={cm['f1_score']:.3f}")
if not any_weak:
lines.append(" (none — all classes recall >= 0.6)")
lines += ["", "SANITY CHECK RESULTS:"]
if sanity_results:
for crop, sr in sanity_results.items():
lines.append(f" {crop:<10} {sr.get('status'):<10} "
f"val_acc={sr.get('val_accuracy')} collapsed={sr.get('collapsed')}")
failed = [c for c, sr in sanity_results.items()
if sr.get("collapsed") or sr.get("status") in ("ERROR", "COLLAPSED")]
lines.append(f" Crops that FAILED sanity check: {failed or 'none'}")
else:
lines.append(" (sanity checks not run)")
lines += [
"",
"TFLITE VERIFICATION:",
]
for crop in results:
_, ver, m = latest_metrics(crop)
tv = m.get("tflite_verified") if (ver and m) else None
lines.append(f" {crop:<10} tflite_verified={tv}")
lines += [
"",
f"MODEL PACKAGE: {ZIP_OUT} "
f"({'CREATED ' + str(round(ZIP_OUT.stat().st_size/(1024**2),1)) + ' MB' if pkg_ok and ZIP_OUT.exists() else 'NOT CREATED'})",
"",
]
text = "\n".join(lines)
print(text, flush=True)
TRAIN_SUMMARY.write_text(text)
log(f"Training summary written to {TRAIN_SUMMARY}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
log("=" * 60)
log("OVERNIGHT TRAINING ORCHESTRATOR STARTED")
log(f"Root: {ROOT}")
log(f"Epochs per crop: {EPOCHS}")
log(f"Val accuracy threshold: {VAL_ACCURACY_THRESHOLD}")
log("=" * 60)
# Kill any existing training processes
try:
r = subprocess.run(
["pgrep", "-f", "train_all_crops|train_crop_model|overnight_orchestrator"],
capture_output=True, text=True
)
pids = [int(p) for p in r.stdout.split() if p.strip().isdigit()
and int(p) != os.getpid()]
for pid in pids:
log(f"Killing existing training process PID {pid}")
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
if pids:
time.sleep(3)
except Exception as e:
log(f"Could not scan/kill existing processes: {e}", "WARN")
crops = list(CROPS.keys()) # corn, soybean, wheat, rice
# Allow skipping already-completed or failed crops via env var SKIP_CROPS
skip = set(os.environ.get("SKIP_CROPS", "").split(","))
for crop in crops:
if crop in skip:
log(f"Skipping {crop} (SKIP_CROPS)")
results[crop] = {"status": "skipped_by_user", "architecture": None,
"val_accuracy": None, "retries": 0}
continue
try:
run_crop(crop)
except KeyboardInterrupt:
log("KeyboardInterrupt — stopping.", "ERROR")
break
except Exception as e:
log(f"Unhandled exception for {crop}: {e}", "ERROR")
_log_file.write(traceback.format_exc() + "\n")
results[crop] = {"status": "unhandled_exception", "architecture": None,
"val_accuracy": None, "retries": 0}
# In SANITY_ONLY mode there are no trained models to package.
pkg_ok = False if SANITY_ONLY else package_models()
write_summary(pkg_ok)
write_training_summary(pkg_ok)
log("ORCHESTRATOR FINISHED")
if __name__ == "__main__":
main()