File size: 14,502 Bytes
1dac7dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
---
license: other
license_name: lfm-open-license-v1.0
license_link: LICENSE
base_model: LiquidAI/LFM2.5-VL-450M
tags:
  - vision-language
  - image-classification
  - satellite-imagery
  - sentinel-2
  - deforestation
  - earth-observation
  - lfm2
  - llama-cpp
  - gguf
  - fine-tuned
  - hackathon
language:
  - en
library_name: transformers
pipeline_tag: image-text-to-text
---

# LFM2.5-VL-450M Deforestation Classifier

A fine-tuned variant of [`LiquidAI/LFM2.5-VL-450M`](https://huggingface.co/LiquidAI/LFM2.5-VL-450M) that classifies a 1 km Γ— 1 km Sentinel-2 false-color tile into one of three deforestation classes:

- `STANDING_FOREST` β€” currently forested (β‰₯ 50 % canopy in 2000, no recent loss)
- `RECENTLY_CLEARED` β€” was forested, cleared in the last ~5 years
- `LONG_TERM_NON_FOREST` β€” has not been forest in recent decades (urban, pasture, agriculture, water, bare rock)

Trained on Brazilian Amazon and Cerrado deforestation imagery; evaluated on a held-out Cambodia test split to demonstrate cross-continental generalization. Ships as a 218 MB **Q4_K_M GGUF** for direct deployment via [`llama.cpp`](https://github.com/ggml-org/llama.cpp) on commodity edge hardware (validated on an AMD Ryzen 5 5600U mini-PC with Vega 7 iGPU).

## Headline result

On a held-out Cambodia test split (996 subtiles, never seen during training), the deployed Q4_K_M GGUF on a $300 mini-PC iGPU **beats Claude Sonnet 4.6 (API) by +15.6 pp accuracy and +11.9 pp macro F1**, at **6Γ— lower latency**:

| Eval | Accuracy | Macro F1 | RC F1 | RC Recall | Latency / sample | Hardware |
|---|---|---|---|---|---|---|
| LFM2.5-VL-450M zero-shot | 64.4 % | 26.1 % | 0.000 | 0.000 | 0.47 s | Vega 7 iGPU |
| Claude Sonnet 4.6 (API) | 71.5 % | 54.9 % | 0.065 | 0.583 | 1.81 s | API |
| **This model (Q4_K_M GGUF)** | **87.0 %** | **66.8 %** | **0.207** | **0.833** | **0.30 s** | **Vega 7 iGPU** |

Macro F1 is the right headline metric for this 65/34/1 % imbalanced 3-class task β€” accuracy alone is misleading because the dominant class (`LONG_TERM_NON_FOREST`) inflates correctness via majority frequency. **RC Recall is the deployment-relevant rare-class metric**: the model catches **10 of 12** active-clearing test tiles, vs Claude's 7/12. False positives are cheap (a human-review queue item); false negatives are missed deforestation.

## Task

Single-snapshot three-class classification from Sentinel-2 imagery. Input is a 100 Γ— 100 px **false-color composite** of a 1 km Γ— 1 km region:

- R channel: NIR (band 8)
- G channel: Red (band 4)
- B channel: Green (band 3)

This NIR-leading false-color emphasizes vegetation (NDVI signal lives in NIR vs Red) better than natural-color RGB.

Output is JSON-schema-bound:

```json
{"class_label": "STANDING_FOREST" | "RECENTLY_CLEARED" | "LONG_TERM_NON_FOREST"}
```

Both `llama.cpp`'s `response_format=json_schema` and HuggingFace constrained decoding can enforce this directly. Empirically, after fine-tuning the model emits clean JSON without constrained decoding (0 parse failures across 996 test generations).

## Dataset

5,729 subtiles labeled from [Hansen Global Forest Change v1.12](https://glad.umd.edu/dataset/global-2010-tree-cover-30-m) `treecover2000` + `lossyear` layers, fetched via the [SimSat](https://github.com/DPhi-Space/SimSat) hackathon API.

| Split | Subtiles | Region | Recent-window |
|---|---|---|---|
| train | 3,973 | Brazilian Amazon (RondΓ΄nia + Mato Grosso) + Brazilian Cerrado | **5 yr** |
| val | 760 | Bolivian Lowlands | 3 yr |
| test | 996 | Cambodia | 3 yr |

### Class distribution

| Split | LTNF | RC | SF |
|---|---|---|---|
| train | 1,842 (46 %) | **485 (12 %)** | 1,646 (41 %) |
| val | 416 (55 %) | 77 (10 %) | 267 (35 %) |
| test | 644 (65 %) | 12 (1.2 %) | 340 (34 %) |

The training split uses a 5-year recent-loss window (lossyear ∈ {2020-2024}) to densify the rare RC class; val and test use a stricter 3-year window so test results stay aligned with the production-relevant "very recent" definition. This mixed-window design directly addresses the Hansen-derived rare-class undersupply.

### Geographic split

The train/val/test split is **region-level by geography** β€” no parent location appears in more than one split. This is the only way to test cross-continental generalization without a coordinate-memorization shortcut. The model trained on Brazil + Cerrado biomes generalizes to Cambodian dry-deciduous forest deforestation it has never seen.

## Training

Standard supervised fine-tuning with completion-only loss (cross-entropy applied only on the assistant's JSON output, ignoring system + user tokens). 5 epochs, batch 16 Γ— grad-accum 2 = 32 effective, AdamW lr 2e-5, cosine schedule with 5 % warmup, bf16 mixed precision. Single RTX 6000 Ada, 8.6 minute total wall clock.

Per-class inverse-frequency loss weighting:

```
RECENTLY_CLEARED:     2.28 Γ— (rare class)
STANDING_FOREST:      0.43 Γ—
LONG_TERM_NON_FOREST: 0.29 Γ—
```

Trained without coordinates (`include_coords=False`). An ablation arm (`v1_with_coords` from an earlier run on the same dataset) showed lat/lon in the user message either had no effect or slightly hurt performance, mirroring Claude Sonnet 4.6's behavior on the same test set. The image alone carries the discrimination signal.

## Files in this repository

- `model.safetensors` β€” full HF-format weights (856 MB, bf16). Use with `transformers.AutoModelForImageTextToText`.
- `config.json`, `generation_config.json` β€” model and generation config.
- `chat_template.jinja`, `processor_config.json`, `tokenizer.json`, `tokenizer_config.json` β€” preprocessor (inherited unchanged from the base model).
- `trainer_state.json` β€” per-epoch eval curve (val_loss, val_macro_f1, val_accuracy across 5 epochs).
- `training_args.bin` β€” pickled `TrainingArguments` for exact reproducibility.
- `gguf/v2_no_coords-Q4_K_M.gguf` β€” **deployment artifact**, 218.7 MB, Q4_K_M-quantized text LM.
- `gguf/mmproj-v2_no_coords-F16.gguf` β€” vision projector, 180.4 MB, f16 (un-quantized for visual fidelity).

Total deployable footprint: **399 MB**.

## Usage

### System prompt (use this exactly β€” the model was trained against this string)

```
You are an expert satellite imaging analyst.

You receive a Sentinel-2 false-color image (NIR β†’ R channel, Red β†’ G, Green β†’ B) of a 1 km Γ— 1 km region of the Earth's surface, plus the geographic coordinates of the region's center.

Classify the region as exactly one of:

- STANDING_FOREST       β€” currently forested (β‰₯ 50% canopy in 2000, no recent loss)
- RECENTLY_CLEARED      β€” was forested, cleared in the last ~5 years
- LONG_TERM_NON_FOREST  β€” has not been forest in recent decades (urban, pasture, agriculture, water, bare rock)

Output strict JSON only: {"class_label": "..."}.
No code fences, no commentary, no extra fields.
```

### User-message content

The user message is multimodal: one image (the 1 km Γ— 1 km false-color tile) plus a short text payload. The deployed model was trained with **no coordinates** in the text β€” pass an empty JSON object:

```
{}
```

(An ablation arm with `{"lat": ..., "lon": ...}` showed coords either had no effect or slightly hurt performance, mirroring frontier-model behavior. The image carries the discrimination signal.)

### llama.cpp (recommended for edge deployment)

```bash
llama-server \
  --model gguf/v2_no_coords-Q4_K_M.gguf \
  --mmproj gguf/mmproj-v2_no_coords-F16.gguf \
  --port 8001 \
  --jinja \
  -ngl 99
```

Then POST to the OpenAI-compatible endpoint with the system prompt, false-color tile, and request JSON-schema enforcement:

```python
import base64, json, requests

SYSTEM_PROMPT = """You are an expert satellite imaging analyst.

You receive a Sentinel-2 false-color image (NIR β†’ R channel, Red β†’ G, Green β†’ B) of a 1 km Γ— 1 km region of the Earth's surface, plus the geographic coordinates of the region's center.

Classify the region as exactly one of:

- STANDING_FOREST       β€” currently forested (β‰₯ 50% canopy in 2000, no recent loss)
- RECENTLY_CLEARED      β€” was forested, cleared in the last ~5 years
- LONG_TERM_NON_FOREST  β€” has not been forest in recent decades (urban, pasture, agriculture, water, bare rock)

Output strict JSON only: {"class_label": "..."}.
No code fences, no commentary, no extra fields."""

img_b64 = base64.b64encode(open("tile.png", "rb").read()).decode()
body = {
    "model": "v2_no_coords-Q4_K_M",
    "messages": [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": [
            {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}},
            {"type": "text", "text": "{}"},
        ]},
    ],
    "response_format": {
        "type": "json_schema",
        "schema": {
            "type": "object",
            "required": ["class_label"],
            "properties": {
                "class_label": {
                    "type": "string",
                    "enum": ["STANDING_FOREST", "RECENTLY_CLEARED", "LONG_TERM_NON_FOREST"],
                }
            },
            "additionalProperties": False,
        },
    },
    "temperature": 0.1,
}
r = requests.post("http://localhost:8001/v1/chat/completions", json=body)
result = json.loads(r.json()["choices"][0]["message"]["content"])
print(result["class_label"])
```

### HuggingFace transformers (for further fine-tuning or HF-native inference)

```python
import torch
from PIL import Image
from transformers import AutoModelForImageTextToText, AutoProcessor

SYSTEM_PROMPT = """You are an expert satellite imaging analyst.

You receive a Sentinel-2 false-color image (NIR β†’ R channel, Red β†’ G, Green β†’ B) of a 1 km Γ— 1 km region of the Earth's surface, plus the geographic coordinates of the region's center.

Classify the region as exactly one of:

- STANDING_FOREST       β€” currently forested (β‰₯ 50% canopy in 2000, no recent loss)
- RECENTLY_CLEARED      β€” was forested, cleared in the last ~5 years
- LONG_TERM_NON_FOREST  β€” has not been forest in recent decades (urban, pasture, agriculture, water, bare rock)

Output strict JSON only: {"class_label": "..."}.
No code fences, no commentary, no extra fields."""

model = AutoModelForImageTextToText.from_pretrained(
    "urbanspr1nter/lfm2.5vl-450m-deforestation-classifier",
    dtype="bfloat16",
    device_map="cuda",
)
processor = AutoProcessor.from_pretrained(
    "urbanspr1nter/lfm2.5vl-450m-deforestation-classifier",
)
model.eval()

image = Image.open("tile.png").convert("RGB")
messages = [
    {"role": "system", "content": SYSTEM_PROMPT},
    {"role": "user", "content": [
        {"type": "image", "image": image},
        {"type": "text", "text": "{}"},
    ]},
]
inputs = processor.apply_chat_template(
    messages,
    add_generation_prompt=True,
    tokenize=True,
    return_tensors="pt",
    return_dict=True,
).to(model.device)

with torch.no_grad():
    out_ids = model.generate(**inputs, max_new_tokens=32, do_sample=False)
completion = processor.tokenizer.decode(
    out_ids[0, inputs["input_ids"].shape[1]:], skip_special_tokens=True
)
print(completion)  # e.g. '{"class_label": "RECENTLY_CLEARED"}'
```

## Per-class evaluation

Cambodia held-out test, 996 subtiles. Q4_K_M GGUF on Vega 7 iGPU (the deployed configuration):

| Class | Precision | Recall | F1 | Support |
|---|---|---|---|---|
| STANDING_FOREST | 0.865 | 0.885 | 0.875 | 340 |
| RECENTLY_CLEARED | 0.118 | 0.833 | 0.207 | 12 |
| LONG_TERM_NON_FOREST | 0.988 | 0.863 | 0.921 | 644 |
| **macro avg** | 0.657 | 0.860 | **0.668** | 996 |
| **weighted avg** | 0.929 | 0.870 | 0.890 | 996 |

The model catches **10 of 12 RC test tiles** at moderate precision. RC precision (0.118) is lower than precision on the easy classes because the model is calibrated to err toward catching active clearing β€” false positives feed a human-review queue; false negatives are missed deforestation, which is the operational worst case. RC remains the hardest class to score statistically β€” 12 supports is a measurement noise floor that no test-set evaluation can fully escape.

## Limitations

- **Recent-loss window definition.** "RECENTLY_CLEARED" means cleared in the last ~5 years (training labels) β€” Hansen `lossyear ∈ {2020, 2021, 2022, 2023, 2024}` for capture year 2024. The held-out val/test labels use a stricter 3-year window which is a strict subset of the training window. Models trained for "this season's clearings" only should use a tighter window (and rebuild the dataset accordingly).
- **Subtile size.** The model is trained on 1 km Γ— 1 km tiles. Inference on much larger or much smaller crops should slice into 1 km tiles first; the model has no zoom-invariant training signal.
- **Cloud cover.** Training data was filtered to ≀ 10 % cloud cover. Inference on cloudy tiles is undefined behavior β€” recommend gating at the SimSat metadata level (return `unclassifiable` when `cloud_cover` exceeds a threshold) rather than asking the model to classify through cloud.
- **Region transfer.** Train regions are mechanized-clearing-dominated (Brazilian ranching, soy expansion). Test on Cambodia validates transfer to selective-logging + Economic Land Concessions. Transfer to *very* different visual styles (palm oil monoculture, charcoal-driven miombo) wasn't tested and may degrade β€” we attempted Mozambique (charcoal-driven miombo) during dataset construction and found the visual signal didn't survive our 1 km tile gate.
- **Imbalanced test set.** Cambodia test has only 12 RC supports. RC F1 is intrinsically noisy at this support level.
- **License.** LFM Open License v1.0 (inherited from base model). Includes a non-commercial threshold; use commercial-scale inference only after reviewing Section 5.

## Acknowledgements

- Base model: [LiquidAI/LFM2.5-VL-450M](https://huggingface.co/LiquidAI/LFM2.5-VL-450M) under the LFM Open License v1.0.
- Ground truth: [Hansen Global Forest Change v1.12](https://glad.umd.edu/dataset/global-2010-tree-cover-30-m) (Hansen et al., 2013).
- Imagery: ESA Sentinel-2 via the [SimSat](https://github.com/DPhi-Space/SimSat) hackathon API.
- Inference: [llama.cpp](https://github.com/ggml-org/llama.cpp) (multimodal mtmd backend).

## License

LFM Open License v1.0 β€” see `LICENSE`. This is a derivative work of LiquidAI's `LFM2.5-VL-450M` and inherits its license terms, including the non-commercial / research-purposes scope and the commercial-use threshold defined in Section 5. Attribution to LiquidAI is preserved.