Instructions to use akashreddy2103/landfill with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use akashreddy2103/landfill with PEFT:
from peft import PeftModel from transformers import AutoModelForCausalLM base_model = AutoModelForCausalLM.from_pretrained("LiquidAI/LFM2.5-VL-450M") model = PeftModel.from_pretrained(base_model, "akashreddy2103/landfill") - Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse files- README.md +59 -9
- benchmark_summary_for_submission.md +23 -11
- fine_tuning_methodology.md +35 -15
- latest_live_scan_artifact.md +7 -7
- latest_live_smoke_proof.md +3 -3
- training_code/ml/training/modal_lora_train.py +391 -128
- tuned_checkpoint_v1.json +39 -14
README.md
CHANGED
|
@@ -6,21 +6,71 @@ tags:
|
|
| 6 |
- lora
|
| 7 |
- vision-language
|
| 8 |
- satellite-imagery
|
|
|
|
| 9 |
- landfill
|
| 10 |
license: apache-2.0
|
| 11 |
---
|
| 12 |
|
| 13 |
-
# LandfillSentry LFM2-VL
|
| 14 |
|
| 15 |
-
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- Training mode: `peft_lora_supervised`
|
| 20 |
- Completed optimizer steps: `24`
|
| 21 |
-
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
| 6 |
- lora
|
| 7 |
- vision-language
|
| 8 |
- satellite-imagery
|
| 9 |
+
- methane-monitoring
|
| 10 |
- landfill
|
| 11 |
license: apache-2.0
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# LandfillSentry LFM2.5-VL Adapter Package
|
| 15 |
|
| 16 |
+
This Hugging Face model repository is reserved for the LandfillSentry
|
| 17 |
+
domain-adapted LFM2.5-VL adapter package.
|
| 18 |
|
| 19 |
+
## Current Status
|
| 20 |
+
|
| 21 |
+
The LandfillSentry application is deployed and verified with live LFM2.5-VL
|
| 22 |
+
inference, DPhi SimSat imagery, strict-live failure behavior, benchmark
|
| 23 |
+
artifacts, and a public PEFT LoRA adapter.
|
| 24 |
+
|
| 25 |
+
Adapter status:
|
| 26 |
+
|
| 27 |
+
- Public adapter repo: this repository
|
| 28 |
+
- Base model: `LiquidAI/LFM2.5-VL-450M`
|
| 29 |
+
- Runtime adapter variable: `HF_ADAPTER_ID=akashreddy2103/landfill`
|
| 30 |
+
- Adapter files: `adapter_config.json`, `adapter_model.safetensors`
|
| 31 |
+
- Latest run id: `lora_run_20260504T181913Z`
|
| 32 |
- Training mode: `peft_lora_supervised`
|
| 33 |
- Completed optimizer steps: `24`
|
| 34 |
+
- Validation loss: `2.410613179206848` before, `1.3696070164442062` after
|
| 35 |
+
|
| 36 |
+
## Included Documentation
|
| 37 |
+
|
| 38 |
+
- `fine_tuning_methodology.md`
|
| 39 |
+
- `benchmark_summary_for_submission.md`
|
| 40 |
+
- `judge_deployment_runbook.md`
|
| 41 |
+
- `latest_live_smoke_proof.md`
|
| 42 |
+
- `latest_live_scan_artifact.md`
|
| 43 |
+
- `dataset_manifest_v1.json`
|
| 44 |
+
- `dataset_splits_v1.json`
|
| 45 |
+
- `phase7_evaluation_report.json`
|
| 46 |
+
- `tuned_checkpoint_v1.json`
|
| 47 |
+
|
| 48 |
+
## Methodology Summary
|
| 49 |
+
|
| 50 |
+
LandfillSentry builds domain-specific satellite evidence panels from DPhi
|
| 51 |
+
SimSat Sentinel imagery, historical Sentinel context, Mapbox context, generated
|
| 52 |
+
candidates, and review labels. Evaluation compares a base model path with a
|
| 53 |
+
domain-adapted path on a small fixture proxy and reports JSON validity,
|
| 54 |
+
incident F1, zone accuracy, bbox IoU, human usefulness, and null-scene false
|
| 55 |
+
positive behavior.
|
| 56 |
+
|
| 57 |
+
## Deployment
|
| 58 |
+
|
| 59 |
+
The verified judge deployment runs with:
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
docker compose --env-file .env.local -f docker-compose.landfillsentry.yml up --build
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
Verified live smoke:
|
| 66 |
+
|
| 67 |
+
- site: `LF_REAL_007`
|
| 68 |
+
- scan: `scan_082`
|
| 69 |
+
- incident: `inc_082`
|
| 70 |
+
- inference mode: `live`
|
| 71 |
+
|
| 72 |
+
## Limitations
|
| 73 |
|
| 74 |
+
The current benchmark is a small domain-adaptation fixture proxy. The adapter
|
| 75 |
+
is real and loadable, but the dataset is still small and partly weak-labeled;
|
| 76 |
+
do not present it as a broad production-quality methane model.
|
benchmark_summary_for_submission.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Benchmark Summary For Submission
|
| 2 |
|
| 3 |
-
Date:
|
| 4 |
|
| 5 |
## Goal
|
| 6 |
|
|
@@ -27,7 +27,13 @@ Public fine-tuned weight claim requires:
|
|
| 27 |
- `HF_ADAPTER_REVISION` set to the judged revision,
|
| 28 |
- model card documenting dataset, splits, methodology, and limitations.
|
| 29 |
|
| 30 |
-
Current
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
## Reproducible Commands
|
| 33 |
|
|
@@ -56,10 +62,10 @@ Copy reviewed rows to `data/labels/manual_label_corrections.csv`, then rebuild/t
|
|
| 56 |
|
| 57 |
5. Save the latest successful live scan artifact:
|
| 58 |
```bash
|
| 59 |
-
python scripts/save_live_scan_artifact.py --api-base-url http://127.0.0.1:8000 --scan-id
|
| 60 |
```
|
| 61 |
|
| 62 |
-
6. Modal fine-tune
|
| 63 |
```bash
|
| 64 |
python scripts/train_lora.py
|
| 65 |
```
|
|
@@ -78,19 +84,19 @@ Generated proof:
|
|
| 78 |
Result from the latest run:
|
| 79 |
- status: `PASS`
|
| 80 |
- site: `LF_REAL_007`
|
| 81 |
-
- scan: `
|
| 82 |
-
- incident: `
|
| 83 |
- inference mode: `live`
|
| 84 |
- previews present: `current_rgb`, `spectral_composite`, `temporal_diff`, `mapbox_context`
|
| 85 |
|
| 86 |
The same scan is archived at:
|
| 87 |
- `docs/latest_live_scan_artifact.md`
|
| 88 |
-
- `data/processed/judge_live_artifacts/
|
| 89 |
|
| 90 |
## Modal LoRA Run
|
| 91 |
|
| 92 |
`python scripts/train_lora.py` completed successfully on Modal after the global expansion:
|
| 93 |
-
- GPU smoke: CUDA available
|
| 94 |
- dataset source: `live_scans`
|
| 95 |
- sample count: `78`
|
| 96 |
- unique sites: `30`
|
|
@@ -98,8 +104,14 @@ The same scan is archived at:
|
|
| 98 |
- regions represented: Europe/legacy, North America, Latin America, Asia, Africa, Middle East
|
| 99 |
- site-based splits: train `49`, validation `20`, test `9`
|
| 100 |
- manifest checksum: `a6738e1af7d89f6fbd0d567c89759f6103beaa81553074a3ad520c6810988b01`
|
| 101 |
-
- run id: `
|
| 102 |
-
- adapter ref: `modal-volume://landfillsentry-model-artifacts/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
- checkpoint record: `data/manifests/tuned_checkpoint_v1.json`
|
| 104 |
|
| 105 |
## Metrics Table
|
|
@@ -115,7 +127,7 @@ Populated from `data/manifests/phase7_evaluation_report.json`.
|
|
| 115 |
| Human usefulness score | 0.7333 | 0.9733 | +0.2400 |
|
| 116 |
| Null-scene false positive rate (lower is better) | 1.00 | 0.00 | -1.00 |
|
| 117 |
|
| 118 |
-
Interpretation: this is a small, reproducible domain-adaptation fixture proxy. The base row is a schema-valid generic LFM2.5-VL projection without landfill-domain zone priors or null-scene caution. The tuned path uses the Phase 6
|
| 119 |
|
| 120 |
## Failure-Handling Table
|
| 121 |
|
|
|
|
| 1 |
# Benchmark Summary For Submission
|
| 2 |
|
| 3 |
+
Date: May 4, 2026
|
| 4 |
|
| 5 |
## Goal
|
| 6 |
|
|
|
|
| 27 |
- `HF_ADAPTER_REVISION` set to the judged revision,
|
| 28 |
- model card documenting dataset, splits, methodology, and limitations.
|
| 29 |
|
| 30 |
+
Current public adapter:
|
| 31 |
+
- repo: `akashreddy2103/landfill`
|
| 32 |
+
- base: `LiquidAI/LFM2.5-VL-450M@main`
|
| 33 |
+
- run id: `lora_run_20260504T181913Z`
|
| 34 |
+
- training mode: `peft_lora_supervised`
|
| 35 |
+
- completed optimizer steps: `24`
|
| 36 |
+
- validation loss: `2.4106` before, `1.3696` after
|
| 37 |
|
| 38 |
## Reproducible Commands
|
| 39 |
|
|
|
|
| 62 |
|
| 63 |
5. Save the latest successful live scan artifact:
|
| 64 |
```bash
|
| 65 |
+
python scripts/save_live_scan_artifact.py --api-base-url http://127.0.0.1:8000 --scan-id scan_082
|
| 66 |
```
|
| 67 |
|
| 68 |
+
6. Modal fine-tune:
|
| 69 |
```bash
|
| 70 |
python scripts/train_lora.py
|
| 71 |
```
|
|
|
|
| 84 |
Result from the latest run:
|
| 85 |
- status: `PASS`
|
| 86 |
- site: `LF_REAL_007`
|
| 87 |
+
- scan: `scan_082`
|
| 88 |
+
- incident: `inc_082`
|
| 89 |
- inference mode: `live`
|
| 90 |
- previews present: `current_rgb`, `spectral_composite`, `temporal_diff`, `mapbox_context`
|
| 91 |
|
| 92 |
The same scan is archived at:
|
| 93 |
- `docs/latest_live_scan_artifact.md`
|
| 94 |
+
- `data/processed/judge_live_artifacts/scan_082.json`
|
| 95 |
|
| 96 |
## Modal LoRA Run
|
| 97 |
|
| 98 |
`python scripts/train_lora.py` completed successfully on Modal after the global expansion:
|
| 99 |
+
- GPU smoke: CUDA available
|
| 100 |
- dataset source: `live_scans`
|
| 101 |
- sample count: `78`
|
| 102 |
- unique sites: `30`
|
|
|
|
| 104 |
- regions represented: Europe/legacy, North America, Latin America, Asia, Africa, Middle East
|
| 105 |
- site-based splits: train `49`, validation `20`, test `9`
|
| 106 |
- manifest checksum: `a6738e1af7d89f6fbd0d567c89759f6103beaa81553074a3ad520c6810988b01`
|
| 107 |
+
- run id: `lora_run_20260504T181913Z`
|
| 108 |
+
- adapter ref: `modal-volume://landfillsentry-model-artifacts/lora_run_20260504T181913Z/checkpoint-lora-v1`
|
| 109 |
+
- public adapter repo: `akashreddy2103/landfill`
|
| 110 |
+
- training mode: `peft_lora_supervised`
|
| 111 |
+
- completed optimizer steps: `24`
|
| 112 |
+
- LoRA rank/alpha/dropout: `8` / `16` / `0.05`
|
| 113 |
+
- validation loss before/after: `2.410613179206848` -> `1.3696070164442062`
|
| 114 |
+
- validation-loss delta: `+1.041006162762642`
|
| 115 |
- checkpoint record: `data/manifests/tuned_checkpoint_v1.json`
|
| 116 |
|
| 117 |
## Metrics Table
|
|
|
|
| 127 |
| Human usefulness score | 0.7333 | 0.9733 | +0.2400 |
|
| 128 |
| Null-scene false positive rate (lower is better) | 1.00 | 0.00 | -1.00 |
|
| 129 |
|
| 130 |
+
Interpretation: this is a small, reproducible domain-adaptation fixture proxy. The base row is a schema-valid generic LFM2.5-VL projection without landfill-domain zone priors or null-scene caution. The tuned path uses the Phase 6 public adapter record, landfill-domain labels, source-zone priors, and strict output validation. The Modal training run also reports a direct validation-loss improvement on held-out LandfillSentry validation samples.
|
| 131 |
|
| 132 |
## Failure-Handling Table
|
| 133 |
|
fine_tuning_methodology.md
CHANGED
|
@@ -1,10 +1,22 @@
|
|
| 1 |
-
# Fine-Tuning Methodology And Public Weights
|
| 2 |
|
| 3 |
## Current Status
|
| 4 |
|
| 5 |
-
LandfillSentry has
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
## Implemented Artifacts
|
| 10 |
|
|
@@ -28,27 +40,35 @@ The frozen dataset manifest contains 78 live-scan-derived samples with site-base
|
|
| 28 |
|
| 29 |
Inputs combine current Sentinel imagery, historical Sentinel context, Mapbox context, generated candidates, panel metadata, and operator-review labels/corrections where available.
|
| 30 |
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
-
2. Train only on the train split and use validation/test splits from `data/manifests/dataset_splits_v1.json`.
|
| 35 |
-
3. Publish the PEFT adapter to Hugging Face with:
|
| 36 |
-
- `adapter_config.json`
|
| 37 |
-
- `adapter_model.safetensors`
|
| 38 |
-
- model card documenting dataset, splits, training parameters, and limitations
|
| 39 |
-
- link to this repo's training/evaluation code
|
| 40 |
|
| 41 |
-
|
| 42 |
|
| 43 |
```powershell
|
| 44 |
-
.\.venv\Scripts\python.exe scripts\upload_hf_adapter.py --adapter-dir
|
| 45 |
```
|
| 46 |
|
| 47 |
Do not run `upload_folder(folder_path=".")`; that can leak `.env.local`, logs, caches, and non-model artifacts.
|
| 48 |
4. Set `.env.local`:
|
| 49 |
|
| 50 |
```env
|
| 51 |
-
HF_ADAPTER_ID=
|
| 52 |
HF_ADAPTER_REVISION=main
|
| 53 |
HF_LOCAL_FILES_ONLY=false
|
| 54 |
```
|
|
@@ -64,4 +84,4 @@ HF_LOCAL_FILES_ONLY=false
|
|
| 64 |
|
| 65 |
## Current Benchmark Interpretation
|
| 66 |
|
| 67 |
-
The current
|
|
|
|
| 1 |
+
# Fine-Tuning Methodology And Public Weights
|
| 2 |
|
| 3 |
## Current Status
|
| 4 |
|
| 5 |
+
LandfillSentry now has a real PEFT LoRA adapter trained on the frozen satellite evidence-panel dataset and published to Hugging Face.
|
| 6 |
|
| 7 |
+
Public adapter repo: `akashreddy2103/landfill`
|
| 8 |
+
|
| 9 |
+
Latest run:
|
| 10 |
+
|
| 11 |
+
- run id: `lora_run_20260504T181913Z`
|
| 12 |
+
- base model: `LiquidAI/LFM2.5-VL-450M@main`
|
| 13 |
+
- training mode: `peft_lora_supervised`
|
| 14 |
+
- completed optimizer steps: `24`
|
| 15 |
+
- LoRA rank/alpha/dropout: `8` / `16` / `0.05`
|
| 16 |
+
- target modules: `k_proj`, `q_proj`, `v_proj`
|
| 17 |
+
- validation loss before: `2.410613179206848`
|
| 18 |
+
- validation loss after: `1.3696070164442062`
|
| 19 |
+
- measured validation-loss delta: `+1.041006162762642`
|
| 20 |
|
| 21 |
## Implemented Artifacts
|
| 22 |
|
|
|
|
| 40 |
|
| 41 |
Inputs combine current Sentinel imagery, historical Sentinel context, Mapbox context, generated candidates, panel metadata, and operator-review labels/corrections where available.
|
| 42 |
|
| 43 |
+
Manifest checksum: `a6738e1af7d89f6fbd0d567c89759f6103beaa81553074a3ad520c6810988b01`
|
| 44 |
+
|
| 45 |
+
## Reproduce Training
|
| 46 |
+
|
| 47 |
+
1. Build/freeze the dataset:
|
| 48 |
+
|
| 49 |
+
```powershell
|
| 50 |
+
.\.venv\Scripts\python.exe scripts\build_phase6_dataset.py
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
2. Run Modal LoRA training:
|
| 54 |
+
|
| 55 |
+
```powershell
|
| 56 |
+
.\.venv\Scripts\python.exe scripts\train_lora.py
|
| 57 |
+
```
|
| 58 |
|
| 59 |
+
Default bounded config trains for up to 24 optimizer steps. Override with environment variables such as `LORA_MAX_STEPS`, `LORA_R`, `LORA_ALPHA`, and `LORA_LEARNING_RATE`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
+
3. Publish only the PEFT adapter folder:
|
| 62 |
|
| 63 |
```powershell
|
| 64 |
+
.\.venv\Scripts\python.exe scripts\upload_hf_adapter.py --adapter-dir data\processed\hf_adapter_trained\lora_run_20260504T181913Z\checkpoint-lora-v1 --repo-id akashreddy2103/landfill
|
| 65 |
```
|
| 66 |
|
| 67 |
Do not run `upload_folder(folder_path=".")`; that can leak `.env.local`, logs, caches, and non-model artifacts.
|
| 68 |
4. Set `.env.local`:
|
| 69 |
|
| 70 |
```env
|
| 71 |
+
HF_ADAPTER_ID=akashreddy2103/landfill
|
| 72 |
HF_ADAPTER_REVISION=main
|
| 73 |
HF_LOCAL_FILES_ONLY=false
|
| 74 |
```
|
|
|
|
| 84 |
|
| 85 |
## Current Benchmark Interpretation
|
| 86 |
|
| 87 |
+
The current public adapter shows measured validation-loss improvement on the frozen LandfillSentry validation subset. The Phase 7 table remains a small domain-adaptation fixture proxy and should not be presented as a broad production-quality benchmark.
|
latest_live_scan_artifact.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Live Scan Artifact
|
| 2 |
|
| 3 |
-
Generated: 2026-05-
|
| 4 |
|
| 5 |
## Runtime
|
| 6 |
|
|
@@ -14,20 +14,20 @@ Generated: 2026-05-04T16:08:14+00:00
|
|
| 14 |
## Scan
|
| 15 |
|
| 16 |
- Site: LF_REAL_007
|
| 17 |
-
- Scan ID:
|
| 18 |
-
- Incident ID:
|
| 19 |
- Status: live
|
| 20 |
- Inference mode: live
|
| 21 |
-
- Model: LiquidAI/LFM2.5-VL-450M@main
|
| 22 |
|
| 23 |
## Incident
|
| 24 |
|
| 25 |
- Priority: high
|
| 26 |
-
- Confidence: 0.
|
| 27 |
- Zone: perimeter_or_unknown
|
| 28 |
- Review status: published
|
| 29 |
|
| 30 |
-
|
| 31 |
|
| 32 |
## DPhi SimSat Provenance
|
| 33 |
|
|
@@ -46,4 +46,4 @@ Endpoints:
|
|
| 46 |
|---|---|---|---:|---|
|
| 47 |
| sentinel_current | dphi-simsat | 2026-05-03T10:36:59+00:00 | 0.8499029899999999 | /app/data/cache/assets/f1/f1ca55cbc7ddef5551b5ac1cb0e7eb047aa7c1c7b08b69442526bdec18118f7a.img |
|
| 48 |
| sentinel_historical | dphi-simsat | 2026-04-26T10:46:55+00:00 | 0.07104859000000001 | /app/data/cache/assets/8d/8ddb0342cfa026aa2591d799b226f23ade86d6e9de4a33a2ffbcfbc6ce4285a8.img |
|
| 49 |
-
| mapbox_context | mapbox | 2026-05-
|
|
|
|
| 1 |
# Live Scan Artifact
|
| 2 |
|
| 3 |
+
Generated: 2026-05-04T18:51:58+00:00
|
| 4 |
|
| 5 |
## Runtime
|
| 6 |
|
|
|
|
| 14 |
## Scan
|
| 15 |
|
| 16 |
- Site: LF_REAL_007
|
| 17 |
+
- Scan ID: scan_082
|
| 18 |
+
- Incident ID: inc_082
|
| 19 |
- Status: live
|
| 20 |
- Inference mode: live
|
| 21 |
+
- Model: LiquidAI/LFM2.5-VL-450M@main+adapter=akashreddy2103/landfill@main
|
| 22 |
|
| 23 |
## Incident
|
| 24 |
|
| 25 |
- Priority: high
|
| 26 |
+
- Confidence: 0.85
|
| 27 |
- Zone: perimeter_or_unknown
|
| 28 |
- Review status: published
|
| 29 |
|
| 30 |
+
The evidence panel indicates a potential landfill site with a high likelihood of contamination. The evidence is from the LF_REAL_007 site and is likely sourced from the perimeter or unknown zone. The evidence is considered to be of high relevance to the site's potential contamination.
|
| 31 |
|
| 32 |
## DPhi SimSat Provenance
|
| 33 |
|
|
|
|
| 46 |
|---|---|---|---:|---|
|
| 47 |
| sentinel_current | dphi-simsat | 2026-05-03T10:36:59+00:00 | 0.8499029899999999 | /app/data/cache/assets/f1/f1ca55cbc7ddef5551b5ac1cb0e7eb047aa7c1c7b08b69442526bdec18118f7a.img |
|
| 48 |
| sentinel_historical | dphi-simsat | 2026-04-26T10:46:55+00:00 | 0.07104859000000001 | /app/data/cache/assets/8d/8ddb0342cfa026aa2591d799b226f23ade86d6e9de4a33a2ffbcfbc6ce4285a8.img |
|
| 49 |
+
| mapbox_context | mapbox | 2026-05-04T18:23:52+00:00 | 0.0 | /app/data/cache/assets/e7/e75506537c03f2f240f03420891530f03a7cc359ea1e5e502981a3d4a46aa80d.img |
|
latest_live_smoke_proof.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Live Smoke Proof
|
| 2 |
|
| 3 |
-
Generated: 2026-05-
|
| 4 |
|
| 5 |
## Command
|
| 6 |
|
|
@@ -12,8 +12,8 @@ python scripts/live_smoke.py --api-base-url http://127.0.0.1:8000 --simsat-base-
|
|
| 12 |
|
| 13 |
- Status: PASS
|
| 14 |
- Site: LF_REAL_007
|
| 15 |
-
- Scan ID:
|
| 16 |
-
- Incident ID:
|
| 17 |
- Inference mode: live
|
| 18 |
- Panel preview keys: current_rgb, spectral_composite, temporal_diff, mapbox_context
|
| 19 |
|
|
|
|
| 1 |
# Live Smoke Proof
|
| 2 |
|
| 3 |
+
Generated: 2026-05-04T18:27:09+00:00
|
| 4 |
|
| 5 |
## Command
|
| 6 |
|
|
|
|
| 12 |
|
| 13 |
- Status: PASS
|
| 14 |
- Site: LF_REAL_007
|
| 15 |
+
- Scan ID: scan_082
|
| 16 |
+
- Incident ID: inc_082
|
| 17 |
- Inference mode: live
|
| 18 |
- Panel preview keys: current_rgb, spectral_composite, temporal_diff, mapbox_context
|
| 19 |
|
training_code/ml/training/modal_lora_train.py
CHANGED
|
@@ -1,180 +1,443 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
|
| 4 |
Usage:
|
| 5 |
-
modal run ml/training/modal_lora_train.py --config-json '{"epochs": 1}'
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
|
|
|
| 10 |
import json
|
| 11 |
import os
|
| 12 |
-
import
|
|
|
|
| 13 |
from datetime import datetime, timezone
|
| 14 |
from pathlib import Path
|
| 15 |
-
from typing import Any, Dict
|
| 16 |
|
| 17 |
import modal
|
| 18 |
|
| 19 |
-
try:
|
| 20 |
-
from ml.training.lora_artifacts import create_training_artifacts
|
| 21 |
-
except Exception:
|
| 22 |
-
# Fallback for Modal remote runtime when only this file is mounted.
|
| 23 |
-
def _normalize_training_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
| 24 |
-
return {
|
| 25 |
-
"model_id": str(config.get("model_id", "LiquidAI/LFM2.5-VL-450M")),
|
| 26 |
-
"revision": str(config.get("revision", "main")),
|
| 27 |
-
"epochs": int(config.get("epochs", 1)),
|
| 28 |
-
"learning_rate": float(config.get("learning_rate", 2e-4)),
|
| 29 |
-
"lora_r": int(config.get("lora_r", 16)),
|
| 30 |
-
"lora_alpha": int(config.get("lora_alpha", 32)),
|
| 31 |
-
"lora_dropout": float(config.get("lora_dropout", 0.05)),
|
| 32 |
-
"dataset_manifest_path": str(
|
| 33 |
-
config.get("dataset_manifest_path", "data/manifests/dataset_manifest_v1.json")
|
| 34 |
-
),
|
| 35 |
-
"dataset_split_path": str(config.get("dataset_split_path", "data/manifests/dataset_splits_v1.json")),
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
def _stable_hash(payload: Dict[str, Any]) -> str:
|
| 39 |
-
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
| 40 |
-
return hashlib.sha256(canonical).hexdigest()
|
| 41 |
-
|
| 42 |
-
def _write_json(path: Path, payload: Dict[str, Any]) -> None:
|
| 43 |
-
path.parent.mkdir(parents=True, exist_ok=True)
|
| 44 |
-
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
| 45 |
-
|
| 46 |
-
def create_training_artifacts(
|
| 47 |
-
artifact_root: Path,
|
| 48 |
-
run_id: str,
|
| 49 |
-
artifact_volume: str,
|
| 50 |
-
config: Dict[str, Any],
|
| 51 |
-
) -> Dict[str, Any]:
|
| 52 |
-
normalized = _normalize_training_config(config)
|
| 53 |
-
run_dir = artifact_root / run_id
|
| 54 |
-
checkpoint_dir = run_dir / "checkpoint-lora-v1"
|
| 55 |
-
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
| 56 |
-
|
| 57 |
-
adapter_config = {
|
| 58 |
-
"base_model_name_or_path": normalized["model_id"],
|
| 59 |
-
"peft_type": "LORA",
|
| 60 |
-
"r": normalized["lora_r"],
|
| 61 |
-
"lora_alpha": normalized["lora_alpha"],
|
| 62 |
-
"lora_dropout": normalized["lora_dropout"],
|
| 63 |
-
"inference_mode": False,
|
| 64 |
-
"task_type": "CAUSAL_LM",
|
| 65 |
-
"note": "Phase 6 scaffold checkpoint artifact.",
|
| 66 |
-
}
|
| 67 |
-
_write_json(checkpoint_dir / "adapter_config.json", adapter_config)
|
| 68 |
-
(checkpoint_dir / "adapter_model.safetensors").write_bytes(
|
| 69 |
-
json.dumps(
|
| 70 |
-
{
|
| 71 |
-
"artifact_type": "phase6.scaffold.weights",
|
| 72 |
-
"note": "Placeholder adapter blob. Replace in full LoRA trainer.",
|
| 73 |
-
},
|
| 74 |
-
sort_keys=True,
|
| 75 |
-
).encode("utf-8")
|
| 76 |
-
)
|
| 77 |
-
_write_json(
|
| 78 |
-
checkpoint_dir / "training_args.json",
|
| 79 |
-
{
|
| 80 |
-
"epochs": normalized["epochs"],
|
| 81 |
-
"learning_rate": normalized["learning_rate"],
|
| 82 |
-
"dataset_manifest_path": normalized["dataset_manifest_path"],
|
| 83 |
-
"dataset_split_path": normalized["dataset_split_path"],
|
| 84 |
-
},
|
| 85 |
-
)
|
| 86 |
-
config_hash = _stable_hash(normalized)
|
| 87 |
-
_write_json(
|
| 88 |
-
run_dir / "run_manifest.json",
|
| 89 |
-
{
|
| 90 |
-
"run_id": run_id,
|
| 91 |
-
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 92 |
-
"artifact_volume": artifact_volume,
|
| 93 |
-
"model_id": normalized["model_id"],
|
| 94 |
-
"revision": normalized["revision"],
|
| 95 |
-
"checkpoint_dir": str(checkpoint_dir),
|
| 96 |
-
"adapter_artifact_ref": f"modal-volume://{artifact_volume}/{run_id}/checkpoint-lora-v1",
|
| 97 |
-
"dataset_manifest_path": normalized["dataset_manifest_path"],
|
| 98 |
-
"dataset_split_path": normalized["dataset_split_path"],
|
| 99 |
-
"config_hash": config_hash,
|
| 100 |
-
"training_mode": "phase6_scaffold",
|
| 101 |
-
},
|
| 102 |
-
)
|
| 103 |
-
return {
|
| 104 |
-
"status": "ok",
|
| 105 |
-
"run_id": run_id,
|
| 106 |
-
"manifest_path": str(run_dir / "run_manifest.json"),
|
| 107 |
-
"checkpoint_dir": str(checkpoint_dir),
|
| 108 |
-
"adapter_artifact_ref": f"modal-volume://{artifact_volume}/{run_id}/checkpoint-lora-v1",
|
| 109 |
-
"artifact_volume": artifact_volume,
|
| 110 |
-
"config_hash": config_hash,
|
| 111 |
-
"training_mode": "phase6_scaffold",
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
|
| 115 |
def _gpu_from_env():
|
| 116 |
-
gpu_name = os.getenv("MODAL_GPU", "
|
| 117 |
allowed = {"T4", "L4", "A10G", "A100"}
|
| 118 |
-
return gpu_name if gpu_name in allowed else "
|
| 119 |
|
| 120 |
|
| 121 |
APP_NAME = os.getenv("MODAL_APP_NAME", "landfillsentry-lora-train")
|
| 122 |
VOLUME_NAME = os.getenv("MODAL_ARTIFACT_VOLUME", "landfillsentry-model-artifacts")
|
| 123 |
ARTIFACT_ROOT = Path("/artifacts")
|
|
|
|
| 124 |
|
| 125 |
image = (
|
| 126 |
modal.Image.debian_slim(python_version="3.11")
|
| 127 |
.pip_install(
|
| 128 |
-
"
|
| 129 |
-
"
|
| 130 |
-
"
|
| 131 |
-
"
|
| 132 |
-
"
|
| 133 |
-
"
|
| 134 |
-
"
|
| 135 |
-
"
|
|
|
|
| 136 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
)
|
| 138 |
volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
|
| 139 |
app = modal.App(APP_NAME)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
import torch
|
| 145 |
|
| 146 |
cuda_available = bool(torch.cuda.is_available())
|
| 147 |
-
gpu_name = torch.cuda.get_device_name(0) if cuda_available else "cpu"
|
| 148 |
return {
|
| 149 |
"cuda_available": cuda_available,
|
| 150 |
-
"device_name":
|
| 151 |
"torch_version": torch.__version__,
|
| 152 |
}
|
| 153 |
|
| 154 |
|
| 155 |
-
@app.function(
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
| 159 |
run_id = f"lora_run_{timestamp}"
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
volume.commit()
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
smoke = gpu_smoke.remote()
|
| 177 |
print("GPU smoke:", smoke)
|
| 178 |
result = run_lora_training.remote(config)
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
print("TRAINING_RESULT_JSON:", json.dumps(result, sort_keys=True))
|
|
|
|
| 1 |
"""
|
| 2 |
+
Modal GPU LoRA trainer for LandfillSentry LFM2-VL domain adaptation.
|
| 3 |
|
| 4 |
Usage:
|
| 5 |
+
modal run ml/training/modal_lora_train.py --config-json '{"epochs": 1, "max_steps": 24}'
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import base64
|
| 11 |
+
import io
|
| 12 |
import json
|
| 13 |
import os
|
| 14 |
+
import random
|
| 15 |
+
import zipfile
|
| 16 |
from datetime import datetime, timezone
|
| 17 |
from pathlib import Path
|
| 18 |
+
from typing import Any, Dict, Iterable, List
|
| 19 |
|
| 20 |
import modal
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
def _gpu_from_env():
|
| 24 |
+
gpu_name = os.getenv("MODAL_GPU", "L4").strip().upper()
|
| 25 |
allowed = {"T4", "L4", "A10G", "A100"}
|
| 26 |
+
return gpu_name if gpu_name in allowed else "L4"
|
| 27 |
|
| 28 |
|
| 29 |
APP_NAME = os.getenv("MODAL_APP_NAME", "landfillsentry-lora-train")
|
| 30 |
VOLUME_NAME = os.getenv("MODAL_ARTIFACT_VOLUME", "landfillsentry-model-artifacts")
|
| 31 |
ARTIFACT_ROOT = Path("/artifacts")
|
| 32 |
+
REMOTE_PROJECT_ROOT = Path("/workspace")
|
| 33 |
|
| 34 |
image = (
|
| 35 |
modal.Image.debian_slim(python_version="3.11")
|
| 36 |
.pip_install(
|
| 37 |
+
"accelerate>=1.0.0",
|
| 38 |
+
"huggingface_hub>=1.0.0",
|
| 39 |
+
"peft>=0.13.0",
|
| 40 |
+
"pillow>=10.0.0",
|
| 41 |
+
"safetensors>=0.4.0",
|
| 42 |
+
"sentencepiece>=0.2.0",
|
| 43 |
+
"torch>=2.0.0",
|
| 44 |
+
"torchvision>=0.20.0",
|
| 45 |
+
"transformers>=5.0.0",
|
| 46 |
)
|
| 47 |
+
.add_local_dir("apps", "/workspace/apps", copy=True)
|
| 48 |
+
.add_local_dir("ml", "/workspace/ml", copy=True)
|
| 49 |
+
.add_local_dir("data/cache/assets", "/workspace/data/cache/assets", copy=True)
|
| 50 |
+
.add_local_dir("data/labels", "/workspace/data/labels", copy=True)
|
| 51 |
+
.add_local_dir("data/manifests", "/workspace/data/manifests", copy=True)
|
| 52 |
)
|
| 53 |
volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
|
| 54 |
app = modal.App(APP_NAME)
|
| 55 |
+
_secret_values = {
|
| 56 |
+
key: value
|
| 57 |
+
for key in ("HF_TOKEN", "HUGGINGFACE_TOKEN")
|
| 58 |
+
if (value := os.getenv(key, "").strip())
|
| 59 |
+
}
|
| 60 |
+
hf_secrets = [modal.Secret.from_dict(_secret_values)] if _secret_values else []
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _utc_now() -> str:
|
| 64 |
+
return datetime.now(timezone.utc).isoformat()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _normalize_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
| 68 |
+
return {
|
| 69 |
+
"model_id": str(config.get("model_id", "LiquidAI/LFM2.5-VL-450M")),
|
| 70 |
+
"revision": str(config.get("revision", "main")),
|
| 71 |
+
"epochs": int(config.get("epochs", 1)),
|
| 72 |
+
"learning_rate": float(config.get("learning_rate", 2e-4)),
|
| 73 |
+
"lora_r": int(config.get("lora_r", 8)),
|
| 74 |
+
"lora_alpha": int(config.get("lora_alpha", 16)),
|
| 75 |
+
"lora_dropout": float(config.get("lora_dropout", 0.05)),
|
| 76 |
+
"max_steps": int(config.get("max_steps", 24)),
|
| 77 |
+
"max_train_samples": int(config.get("max_train_samples", 49)),
|
| 78 |
+
"max_eval_samples": int(config.get("max_eval_samples", 8)),
|
| 79 |
+
"seed": int(config.get("seed", 2103)),
|
| 80 |
+
"dataset_manifest_path": str(config.get("dataset_manifest_path", "data/manifests/dataset_manifest_v1.json")),
|
| 81 |
+
"dataset_split_path": str(config.get("dataset_split_path", "data/manifests/dataset_splits_v1.json")),
|
| 82 |
+
"hf_token": str(config.get("hf_token") or os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_TOKEN") or ""),
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _project_path(path: str | Path) -> Path:
|
| 87 |
+
candidate = Path(path)
|
| 88 |
+
if candidate.is_absolute():
|
| 89 |
+
return candidate
|
| 90 |
+
return REMOTE_PROJECT_ROOT / candidate
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _load_json(path: Path) -> Dict[str, Any]:
|
| 94 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _load_samples(config: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]:
|
| 98 |
+
manifest = _load_json(_project_path(config["dataset_manifest_path"]))
|
| 99 |
+
by_id = {sample["sample_id"]: sample for sample in manifest.get("samples", [])}
|
| 100 |
+
split_doc = _load_json(_project_path(config["dataset_split_path"]))
|
| 101 |
+
split_ids = split_doc.get("splits", {})
|
| 102 |
+
return {
|
| 103 |
+
split: [by_id[sample_id] for sample_id in sample_ids if sample_id in by_id]
|
| 104 |
+
for split, sample_ids in split_ids.items()
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _panel_image_path(sample: Dict[str, Any]) -> Path:
|
| 109 |
+
panel_path = _project_path(sample["panel_artifact_path"])
|
| 110 |
+
panel = _load_json(panel_path)
|
| 111 |
+
slots = panel.get("slots", {})
|
| 112 |
+
image_ref = slots.get("current_rgb") or slots.get("spectral_composite") or slots.get("mapbox_context")
|
| 113 |
+
if not image_ref:
|
| 114 |
+
raise ValueError(f"panel has no usable image slot: {panel_path}")
|
| 115 |
+
return _project_path(image_ref)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _prompt_text(sample: Dict[str, Any]) -> tuple[str, str]:
|
| 119 |
+
panel = _load_json(_project_path(sample["panel_artifact_path"]))
|
| 120 |
+
metadata_block = panel.get("metadata_text") or (
|
| 121 |
+
f"site_id={sample['site_id']}; sample_id={sample['sample_id']}; "
|
| 122 |
+
f"source_ref={sample.get('provenance', {}).get('source_ref', 'unknown')}"
|
| 123 |
+
)
|
| 124 |
+
system_prompt = (
|
| 125 |
+
"You are LandfillSentry Incident Assistant. Return JSON only. "
|
| 126 |
+
"Do not include markdown or narrative. Use enum values exactly as specified."
|
| 127 |
+
)
|
| 128 |
+
user_prompt = (
|
| 129 |
+
"Interpret the evidence panel and produce an incident object using this schema: "
|
| 130 |
+
"{incident_id, site_id, job_id, analysis_time, plume_likely, confidence, bbox_norm, "
|
| 131 |
+
"likely_source_zone, persistence_score, priority_tier, severity_tier, review_status, "
|
| 132 |
+
"feedback_status, evidence_summary, recommended_followup, model_version}. "
|
| 133 |
+
f"Metadata: {metadata_block}."
|
| 134 |
+
)
|
| 135 |
+
return system_prompt, user_prompt
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _target_json(sample: Dict[str, Any], model_id: str) -> str:
|
| 139 |
+
annotation = sample["annotation"]
|
| 140 |
+
priority = str(annotation.get("priority_tier", "medium"))
|
| 141 |
+
severity = "critical" if priority == "urgent" else "high" if priority == "high" else "medium" if priority == "medium" else "low"
|
| 142 |
+
payload = {
|
| 143 |
+
"incident_id": f"train_{sample['sample_id']}",
|
| 144 |
+
"site_id": sample["site_id"],
|
| 145 |
+
"job_id": sample["sample_id"],
|
| 146 |
+
"analysis_time": sample.get("provenance", {}).get("created_at", _utc_now()),
|
| 147 |
+
"plume_likely": bool(annotation.get("plume_likely", True)),
|
| 148 |
+
"confidence": 0.85 if annotation.get("plume_likely", True) else 0.35,
|
| 149 |
+
"bbox_norm": annotation.get("bbox_norm", [0.2, 0.2, 0.5, 0.5]),
|
| 150 |
+
"likely_source_zone": annotation.get("likely_source_zone", "perimeter_or_unknown"),
|
| 151 |
+
"persistence_score": 0.74 if annotation.get("plume_likely", True) else 0.18,
|
| 152 |
+
"priority_tier": priority,
|
| 153 |
+
"severity_tier": severity,
|
| 154 |
+
"review_status": "needs_review",
|
| 155 |
+
"feedback_status": "unreviewed",
|
| 156 |
+
"evidence_summary": f"Domain-labeled landfill sample {sample['sample_id']} indicates {priority} review priority.",
|
| 157 |
+
"recommended_followup": "Review the evidence panel, confirm source zone, and compare with recent temporal context.",
|
| 158 |
+
"model_version": model_id,
|
| 159 |
+
}
|
| 160 |
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _conversation(sample: Dict[str, Any], image: Any, model_id: str, include_answer: bool) -> List[Dict[str, Any]]:
|
| 164 |
+
system_prompt, user_prompt = _prompt_text(sample)
|
| 165 |
+
messages = [
|
| 166 |
+
{"role": "system", "content": [{"type": "text", "text": system_prompt}]},
|
| 167 |
+
{
|
| 168 |
+
"role": "user",
|
| 169 |
+
"content": [
|
| 170 |
+
{"type": "image", "image": image},
|
| 171 |
+
{"type": "text", "text": user_prompt},
|
| 172 |
+
],
|
| 173 |
+
},
|
| 174 |
+
]
|
| 175 |
+
if include_answer:
|
| 176 |
+
messages.append({"role": "assistant", "content": [{"type": "text", "text": _target_json(sample, model_id)}]})
|
| 177 |
+
return messages
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _move_to_device(batch: Dict[str, Any], device: str) -> Dict[str, Any]:
|
| 181 |
+
moved: Dict[str, Any] = {}
|
| 182 |
+
for key, value in batch.items():
|
| 183 |
+
moved[key] = value.to(device) if hasattr(value, "to") else value
|
| 184 |
+
return moved
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _encode_sample(processor: Any, sample: Dict[str, Any], model_id: str) -> Dict[str, Any]:
|
| 188 |
+
from PIL import Image
|
| 189 |
+
|
| 190 |
+
image_path = _panel_image_path(sample)
|
| 191 |
+
image = Image.open(image_path).convert("RGB")
|
| 192 |
+
prompt_messages = _conversation(sample, image, model_id, include_answer=False)
|
| 193 |
+
full_messages = _conversation(sample, image, model_id, include_answer=True)
|
| 194 |
+
full = processor.apply_chat_template(
|
| 195 |
+
full_messages,
|
| 196 |
+
add_generation_prompt=False,
|
| 197 |
+
return_tensors="pt",
|
| 198 |
+
return_dict=True,
|
| 199 |
+
tokenize=True,
|
| 200 |
+
)
|
| 201 |
+
prompt = processor.apply_chat_template(
|
| 202 |
+
prompt_messages,
|
| 203 |
+
add_generation_prompt=True,
|
| 204 |
+
return_tensors="pt",
|
| 205 |
+
return_dict=True,
|
| 206 |
+
tokenize=True,
|
| 207 |
+
)
|
| 208 |
+
labels = full["input_ids"].clone()
|
| 209 |
+
prompt_len = min(prompt["input_ids"].shape[1], labels.shape[1] - 1)
|
| 210 |
+
labels[:, :prompt_len] = -100
|
| 211 |
+
full["labels"] = labels
|
| 212 |
+
return full
|
| 213 |
|
| 214 |
|
| 215 |
+
def _linear_target_modules(model: Any) -> List[str]:
|
| 216 |
+
import torch
|
| 217 |
+
|
| 218 |
+
preferred = {"q_proj", "k_proj", "v_proj", "o_proj"}
|
| 219 |
+
found = set()
|
| 220 |
+
fallback = set()
|
| 221 |
+
for name, module in model.named_modules():
|
| 222 |
+
if not isinstance(module, torch.nn.Linear):
|
| 223 |
+
continue
|
| 224 |
+
short = name.rsplit(".", 1)[-1]
|
| 225 |
+
if short in preferred:
|
| 226 |
+
found.add(short)
|
| 227 |
+
elif len(fallback) < 8:
|
| 228 |
+
fallback.add(short)
|
| 229 |
+
return sorted(found or fallback)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _average_loss(
|
| 233 |
+
model: Any,
|
| 234 |
+
processor: Any,
|
| 235 |
+
samples: Iterable[Dict[str, Any]],
|
| 236 |
+
model_id: str,
|
| 237 |
+
device: str,
|
| 238 |
+
) -> float | None:
|
| 239 |
+
import torch
|
| 240 |
+
|
| 241 |
+
losses: List[float] = []
|
| 242 |
+
model.eval()
|
| 243 |
+
with torch.no_grad():
|
| 244 |
+
for sample in samples:
|
| 245 |
+
batch = _move_to_device(_encode_sample(processor, sample, model_id), device)
|
| 246 |
+
loss = model(**batch).loss
|
| 247 |
+
losses.append(float(loss.detach().cpu()))
|
| 248 |
+
model.train()
|
| 249 |
+
if not losses:
|
| 250 |
+
return None
|
| 251 |
+
return sum(losses) / len(losses)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _archive_dir_b64(path: Path) -> str:
|
| 255 |
+
buffer = io.BytesIO()
|
| 256 |
+
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
| 257 |
+
for item in sorted(path.rglob("*")):
|
| 258 |
+
if item.is_file():
|
| 259 |
+
zf.write(item, item.relative_to(path).as_posix())
|
| 260 |
+
return base64.b64encode(buffer.getvalue()).decode("ascii")
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@app.function(image=image, gpu=_gpu_from_env(), timeout=60 * 10)
|
| 264 |
+
def gpu_smoke() -> Dict[str, Any]:
|
| 265 |
import torch
|
| 266 |
|
| 267 |
cuda_available = bool(torch.cuda.is_available())
|
|
|
|
| 268 |
return {
|
| 269 |
"cuda_available": cuda_available,
|
| 270 |
+
"device_name": torch.cuda.get_device_name(0) if cuda_available else "cpu",
|
| 271 |
"torch_version": torch.__version__,
|
| 272 |
}
|
| 273 |
|
| 274 |
|
| 275 |
+
@app.function(
|
| 276 |
+
image=image,
|
| 277 |
+
gpu=_gpu_from_env(),
|
| 278 |
+
timeout=60 * 120,
|
| 279 |
+
volumes={str(ARTIFACT_ROOT): volume},
|
| 280 |
+
secrets=hf_secrets,
|
| 281 |
+
)
|
| 282 |
+
def run_lora_training(config: Dict[str, Any]) -> Dict[str, Any]:
|
| 283 |
+
import torch
|
| 284 |
+
from peft import LoraConfig, get_peft_model
|
| 285 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 286 |
+
|
| 287 |
+
cfg = _normalize_config(config)
|
| 288 |
+
random.seed(cfg["seed"])
|
| 289 |
+
torch.manual_seed(cfg["seed"])
|
| 290 |
+
if torch.cuda.is_available():
|
| 291 |
+
torch.cuda.manual_seed_all(cfg["seed"])
|
| 292 |
+
|
| 293 |
+
samples_by_split = _load_samples(cfg)
|
| 294 |
+
train_samples = list(samples_by_split.get("train", []))[: cfg["max_train_samples"]]
|
| 295 |
+
eval_samples = (list(samples_by_split.get("validation", [])) or list(samples_by_split.get("test", [])))[: cfg["max_eval_samples"]]
|
| 296 |
+
if not train_samples:
|
| 297 |
+
raise RuntimeError("no train samples found in frozen split manifest")
|
| 298 |
+
|
| 299 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 300 |
+
dtype = torch.float16 if device == "cuda" else torch.float32
|
| 301 |
+
token = cfg["hf_token"] or None
|
| 302 |
+
load_kwargs: Dict[str, Any] = {
|
| 303 |
+
"revision": cfg["revision"],
|
| 304 |
+
"trust_remote_code": True,
|
| 305 |
+
"dtype": dtype,
|
| 306 |
+
}
|
| 307 |
+
processor_kwargs: Dict[str, Any] = {"revision": cfg["revision"], "trust_remote_code": True}
|
| 308 |
+
if token:
|
| 309 |
+
load_kwargs["token"] = token
|
| 310 |
+
processor_kwargs["token"] = token
|
| 311 |
+
|
| 312 |
+
processor = AutoProcessor.from_pretrained(cfg["model_id"], **processor_kwargs)
|
| 313 |
+
model = AutoModelForImageTextToText.from_pretrained(cfg["model_id"], **load_kwargs)
|
| 314 |
+
model.to(device)
|
| 315 |
+
model.config.use_cache = False
|
| 316 |
+
|
| 317 |
+
target_modules = _linear_target_modules(model)
|
| 318 |
+
peft_config = LoraConfig(
|
| 319 |
+
r=cfg["lora_r"],
|
| 320 |
+
lora_alpha=cfg["lora_alpha"],
|
| 321 |
+
target_modules=target_modules,
|
| 322 |
+
lora_dropout=cfg["lora_dropout"],
|
| 323 |
+
bias="none",
|
| 324 |
+
task_type="CAUSAL_LM",
|
| 325 |
+
)
|
| 326 |
+
model = get_peft_model(model, peft_config)
|
| 327 |
+
model.train()
|
| 328 |
+
|
| 329 |
+
eval_loss_before = _average_loss(model, processor, eval_samples, cfg["model_id"], device)
|
| 330 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=cfg["learning_rate"])
|
| 331 |
+
train_losses: List[float] = []
|
| 332 |
+
completed_steps = 0
|
| 333 |
+
|
| 334 |
+
for _epoch in range(cfg["epochs"]):
|
| 335 |
+
for sample in train_samples:
|
| 336 |
+
if cfg["max_steps"] > 0 and completed_steps >= cfg["max_steps"]:
|
| 337 |
+
break
|
| 338 |
+
optimizer.zero_grad(set_to_none=True)
|
| 339 |
+
batch = _move_to_device(_encode_sample(processor, sample, cfg["model_id"]), device)
|
| 340 |
+
loss = model(**batch).loss
|
| 341 |
+
loss.backward()
|
| 342 |
+
optimizer.step()
|
| 343 |
+
train_losses.append(float(loss.detach().cpu()))
|
| 344 |
+
completed_steps += 1
|
| 345 |
+
if cfg["max_steps"] > 0 and completed_steps >= cfg["max_steps"]:
|
| 346 |
+
break
|
| 347 |
+
|
| 348 |
+
eval_loss_after = _average_loss(model, processor, eval_samples, cfg["model_id"], device)
|
| 349 |
+
|
| 350 |
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
| 351 |
run_id = f"lora_run_{timestamp}"
|
| 352 |
+
run_dir = ARTIFACT_ROOT / run_id
|
| 353 |
+
checkpoint_dir = run_dir / "checkpoint-lora-v1"
|
| 354 |
+
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
| 355 |
+
model.save_pretrained(checkpoint_dir)
|
| 356 |
+
processor.save_pretrained(checkpoint_dir / "processor")
|
| 357 |
+
|
| 358 |
+
manifest = _load_json(_project_path(cfg["dataset_manifest_path"]))
|
| 359 |
+
metadata = {
|
| 360 |
+
"adapter_status": "fine_tuned",
|
| 361 |
+
"base_model": cfg["model_id"],
|
| 362 |
+
"base_revision": cfg["revision"],
|
| 363 |
+
"completed_steps": completed_steps,
|
| 364 |
+
"created_at": _utc_now(),
|
| 365 |
+
"dataset_manifest_checksum": manifest.get("manifest_checksum"),
|
| 366 |
+
"dataset_sample_count": manifest.get("sample_count"),
|
| 367 |
+
"eval_loss_after": eval_loss_after,
|
| 368 |
+
"eval_loss_before": eval_loss_before,
|
| 369 |
+
"eval_loss_delta": None
|
| 370 |
+
if eval_loss_before is None or eval_loss_after is None
|
| 371 |
+
else eval_loss_before - eval_loss_after,
|
| 372 |
+
"lora_alpha": cfg["lora_alpha"],
|
| 373 |
+
"lora_dropout": cfg["lora_dropout"],
|
| 374 |
+
"lora_r": cfg["lora_r"],
|
| 375 |
+
"max_steps": cfg["max_steps"],
|
| 376 |
+
"target_modules": target_modules,
|
| 377 |
+
"train_loss_mean": None if not train_losses else sum(train_losses) / len(train_losses),
|
| 378 |
+
"train_sample_count_used": len(train_samples),
|
| 379 |
+
"validation_sample_count_used": len(eval_samples),
|
| 380 |
+
}
|
| 381 |
+
(checkpoint_dir / "landfillsentry_adapter_metadata.json").write_text(
|
| 382 |
+
json.dumps(metadata, indent=2, sort_keys=True),
|
| 383 |
+
encoding="utf-8",
|
| 384 |
+
)
|
| 385 |
+
(checkpoint_dir / "README.md").write_text(
|
| 386 |
+
"# LandfillSentry LFM2-VL LoRA Adapter\n\n"
|
| 387 |
+
"PEFT LoRA adapter trained on the frozen LandfillSentry satellite evidence-panel dataset.\n\n"
|
| 388 |
+
f"- Base model: `{cfg['model_id']}@{cfg['revision']}`\n"
|
| 389 |
+
f"- Completed optimizer steps: `{completed_steps}`\n"
|
| 390 |
+
f"- Validation loss before: `{eval_loss_before}`\n"
|
| 391 |
+
f"- Validation loss after: `{eval_loss_after}`\n"
|
| 392 |
+
f"- Dataset checksum: `{manifest.get('manifest_checksum')}`\n",
|
| 393 |
+
encoding="utf-8",
|
| 394 |
)
|
| 395 |
+
|
| 396 |
+
run_manifest = {
|
| 397 |
+
"adapter_artifact_ref": f"modal-volume://{VOLUME_NAME}/{run_id}/checkpoint-lora-v1",
|
| 398 |
+
"artifact_volume": VOLUME_NAME,
|
| 399 |
+
"checkpoint_dir": str(checkpoint_dir),
|
| 400 |
+
"config": {k: v for k, v in cfg.items() if k != "hf_token"},
|
| 401 |
+
"created_at": _utc_now(),
|
| 402 |
+
"manifest_path": str(run_dir / "run_manifest.json"),
|
| 403 |
+
"metrics": metadata,
|
| 404 |
+
"model_id": cfg["model_id"],
|
| 405 |
+
"revision": cfg["revision"],
|
| 406 |
+
"run_id": run_id,
|
| 407 |
+
"status": "ok",
|
| 408 |
+
"training_mode": "peft_lora_supervised",
|
| 409 |
+
}
|
| 410 |
+
(run_dir / "run_manifest.json").write_text(json.dumps(run_manifest, indent=2, sort_keys=True), encoding="utf-8")
|
| 411 |
volume.commit()
|
| 412 |
+
|
| 413 |
+
return {
|
| 414 |
+
**run_manifest,
|
| 415 |
+
"adapter_archive_b64": _archive_dir_b64(checkpoint_dir),
|
| 416 |
+
}
|
| 417 |
|
| 418 |
|
| 419 |
+
def _extract_adapter_archive(result: Dict[str, Any], output_root: Path) -> Path | None:
|
| 420 |
+
archive_b64 = result.pop("adapter_archive_b64", None)
|
| 421 |
+
if not archive_b64:
|
| 422 |
+
return None
|
| 423 |
+
run_id = str(result.get("run_id", "lora_run_latest"))
|
| 424 |
+
out_dir = output_root / run_id / "checkpoint-lora-v1"
|
| 425 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 426 |
+
data = base64.b64decode(archive_b64.encode("ascii"))
|
| 427 |
+
with zipfile.ZipFile(io.BytesIO(data), mode="r") as zf:
|
| 428 |
+
zf.extractall(out_dir)
|
| 429 |
+
return out_dir
|
| 430 |
|
| 431 |
+
|
| 432 |
+
@app.local_entrypoint()
|
| 433 |
+
def main(config_json: str = "", output_dir: str = "data/processed/hf_adapter_trained") -> None:
|
| 434 |
+
config = json.loads(config_json) if config_json else {}
|
| 435 |
smoke = gpu_smoke.remote()
|
| 436 |
print("GPU smoke:", smoke)
|
| 437 |
result = run_lora_training.remote(config)
|
| 438 |
+
local_adapter_dir = _extract_adapter_archive(result, Path(output_dir))
|
| 439 |
+
if local_adapter_dir is not None:
|
| 440 |
+
result["local_adapter_dir"] = str(local_adapter_dir)
|
| 441 |
+
print("LOCAL_ADAPTER_DIR:", local_adapter_dir)
|
| 442 |
+
print("Training result:", result)
|
| 443 |
print("TRAINING_RESULT_JSON:", json.dumps(result, sort_keys=True))
|
tuned_checkpoint_v1.json
CHANGED
|
@@ -1,14 +1,39 @@
|
|
| 1 |
-
{
|
| 2 |
-
"generated_by": "
|
| 3 |
-
"record_version": "phase6.checkpoint.v1",
|
| 4 |
-
"result": {
|
| 5 |
-
"adapter_artifact_ref": "modal-volume://landfillsentry-model-artifacts/
|
| 6 |
-
"artifact_volume": "landfillsentry-model-artifacts",
|
| 7 |
-
"checkpoint_dir": "/
|
| 8 |
-
"
|
| 9 |
-
"
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"generated_by": "ml/training/modal_lora_train.py",
|
| 3 |
+
"record_version": "phase6.checkpoint.v1",
|
| 4 |
+
"result": {
|
| 5 |
+
"adapter_artifact_ref": "modal-volume://landfillsentry-model-artifacts/lora_run_20260504T181913Z/checkpoint-lora-v1",
|
| 6 |
+
"artifact_volume": "landfillsentry-model-artifacts",
|
| 7 |
+
"checkpoint_dir": "data/processed/hf_adapter_trained/lora_run_20260504T181913Z/checkpoint-lora-v1",
|
| 8 |
+
"local_adapter_dir": "data/processed/hf_adapter_trained/lora_run_20260504T181913Z/checkpoint-lora-v1",
|
| 9 |
+
"metrics": {
|
| 10 |
+
"adapter_status": "fine_tuned",
|
| 11 |
+
"base_model": "LiquidAI/LFM2.5-VL-450M",
|
| 12 |
+
"base_revision": "main",
|
| 13 |
+
"completed_steps": 24,
|
| 14 |
+
"created_at": "2026-05-04T18:19:13.913342+00:00",
|
| 15 |
+
"dataset_manifest_checksum": "a6738e1af7d89f6fbd0d567c89759f6103beaa81553074a3ad520c6810988b01",
|
| 16 |
+
"dataset_sample_count": 78,
|
| 17 |
+
"eval_loss_after": 1.3696070164442062,
|
| 18 |
+
"eval_loss_before": 2.410613179206848,
|
| 19 |
+
"eval_loss_delta": 1.041006162762642,
|
| 20 |
+
"lora_alpha": 16,
|
| 21 |
+
"lora_dropout": 0.05,
|
| 22 |
+
"lora_r": 8,
|
| 23 |
+
"max_steps": 24,
|
| 24 |
+
"target_modules": [
|
| 25 |
+
"k_proj",
|
| 26 |
+
"q_proj",
|
| 27 |
+
"v_proj"
|
| 28 |
+
],
|
| 29 |
+
"train_loss_mean": 1.8549085905154545,
|
| 30 |
+
"train_sample_count_used": 49,
|
| 31 |
+
"validation_sample_count_used": 8
|
| 32 |
+
},
|
| 33 |
+
"model_id": "LiquidAI/LFM2.5-VL-450M",
|
| 34 |
+
"revision": "main",
|
| 35 |
+
"run_id": "lora_run_20260504T181913Z",
|
| 36 |
+
"status": "ok",
|
| 37 |
+
"training_mode": "peft_lora_supervised"
|
| 38 |
+
}
|
| 39 |
+
}
|