deepSSF 0.2.3__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. deepssf-0.3.0/CHANGELOG.md +289 -0
  2. deepssf-0.3.0/Habitat_movement_loss_balancing.md +237 -0
  3. {deepssf-0.2.3 → deepssf-0.3.0}/PKG-INFO +4 -2
  4. deepssf-0.3.0/examples/deepssf_train_validate_example_v0_3_0.ipynb +2535 -0
  5. {deepssf-0.2.3 → deepssf-0.3.0}/pyproject.toml +5 -0
  6. {deepssf-0.2.3 → deepssf-0.3.0}/src/deepssf/__init__.py +33 -2
  7. {deepssf-0.2.3 → deepssf-0.3.0}/src/deepssf/data.py +190 -60
  8. deepssf-0.3.0/src/deepssf/model.py +592 -0
  9. deepssf-0.3.0/src/deepssf/plot.py +420 -0
  10. deepssf-0.3.0/src/deepssf/predict.py +178 -0
  11. deepssf-0.3.0/src/deepssf/simulate.py +928 -0
  12. deepssf-0.3.0/src/deepssf/train.py +1596 -0
  13. {deepssf-0.2.3 → deepssf-0.3.0}/src/deepssf/utils.py +27 -2
  14. {deepssf-0.2.3 → deepssf-0.3.0}/src/deepssf/validate.py +13 -5
  15. deepssf-0.3.0/tests/test_deepssf.py +2717 -0
  16. deepssf-0.2.3/CHANGELOG.md +0 -10
  17. deepssf-0.2.3/examples/deepssf_train_validate_example.ipynb +0 -2523
  18. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-15-output-1.png +0 -0
  19. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-17-output-1.png +0 -0
  20. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-19-output-1.png +0 -0
  21. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-21-output-1.png +0 -0
  22. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-23-output-1.png +0 -0
  23. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-24-output-1.png +0 -0
  24. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-27-output-1.png +0 -0
  25. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-28-output-1.png +0 -0
  26. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-30-output-1.png +0 -0
  27. deepssf-0.2.3/src/deepssf/model.py +0 -362
  28. deepssf-0.2.3/src/deepssf/simulate.py +0 -313
  29. deepssf-0.2.3/src/deepssf/train.py +0 -576
  30. deepssf-0.2.3/tests/test_deepssf.py +0 -842
  31. {deepssf-0.2.3 → deepssf-0.3.0}/.github/workflows/ci.yml +0 -0
  32. {deepssf-0.2.3 → deepssf-0.3.0}/.github/workflows/publish.yml +0 -0
  33. {deepssf-0.2.3 → deepssf-0.3.0}/.gitignore +0 -0
  34. {deepssf-0.2.3 → deepssf-0.3.0}/.pre-commit-config.yaml +0 -0
  35. {deepssf-0.2.3 → deepssf-0.3.0}/CITATION.cff +0 -0
  36. {deepssf-0.2.3 → deepssf-0.3.0}/LICENSE +0 -0
  37. {deepssf-0.2.3 → deepssf-0.3.0}/Package_generation_process.md +0 -0
  38. {deepssf-0.2.3 → deepssf-0.3.0}/README.md +0 -0
  39. {deepssf-0.2.3 → deepssf-0.3.0}/environment.yml +0 -0
  40. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example.html +0 -0
  41. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/figure-html/cell-25-output-1.png +0 -0
  42. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/figure-html/cell-29-output-1.png +0 -0
  43. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/figure-html/cell-32-output-1.png +0 -0
  44. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/figure-html/cell-7-output-1.png +0 -0
  45. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap-138a6193a3bd40baf1e627da441a4734.min.css +0 -0
  46. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap-icons.css +0 -0
  47. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap-icons.woff +0 -0
  48. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap.min.js +0 -0
  49. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/clipboard/clipboard.min.js +0 -0
  50. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/quarto-html/anchor.min.js +0 -0
  51. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/quarto-html/popper.min.js +0 -0
  52. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/quarto-html/quarto-syntax-highlighting-7f8f88aac4f3542376d5c11b86a4c14d.css +0 -0
  53. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/quarto-html/quarto.js +0 -0
  54. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/quarto-html/tabsets/tabsets.js +0 -0
  55. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/quarto-html/tippy.css +0 -0
  56. {deepssf-0.2.3 → deepssf-0.3.0}/examples/deepssf_train_validate_example_files/libs/quarto-html/tippy.umd.min.js +0 -0
  57. {deepssf-0.2.3 → deepssf-0.3.0}/examples/references.bib +0 -0
  58. {deepssf-0.2.3 → deepssf-0.3.0}/icons/both_icons.png +0 -0
  59. {deepssf-0.2.3 → deepssf-0.3.0}/icons/deepSSF_hex_textured_Python.png +0 -0
  60. {deepssf-0.2.3 → deepssf-0.3.0}/icons/deepSSF_hex_textured_R.png +0 -0
  61. {deepssf-0.2.3 → deepssf-0.3.0}/src/deepssf/datasets/data/buffalo_djelk_id2005.csv +0 -0
  62. {deepssf-0.2.3 → deepssf-0.3.0}/src/deepssf/datasets/data/ndvi_2005.tif +0 -0
  63. {deepssf-0.2.3 → deepssf-0.3.0}/src/deepssf/datasets/data/slope_2005.tif +0 -0
  64. {deepssf-0.2.3 → deepssf-0.3.0}/styles.css +0 -0
  65. {deepssf-0.2.3 → deepssf-0.3.0}/tests/__init__.py +0 -0
  66. {deepssf-0.2.3 → deepssf-0.3.0}/tests/test_smoke.py +0 -0
@@ -0,0 +1,289 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.3.0] - 2026-10-23
8
+
9
+ ### Added
10
+ - **Configurable convolutional depth** — `ModelParams` now takes
11
+ `n_conv_layers_hab` and `n_conv_layers_move`, setting the depth of the two
12
+ sub-networks independently. The habitat count includes the final convolution
13
+ that projects to one output channel (so the minimum is 1), and since that
14
+ block does no pooling, extra layers only deepen the features and widen the
15
+ receptive field. The movement count is the number of conv → ReLU → max-pool
16
+ blocks, so it also controls how far the window is reduced before the
17
+ fully-connected layers. Both keys are optional and default to the paper
18
+ architecture (4 and 3), so existing params dicts and checkpoints are
19
+ unaffected — but note that a checkpoint only loads into a model with the same
20
+ layer counts it was trained with.
21
+ - **`deepssf.model.flattened_conv_dim`** — compute the flattened size of the
22
+ movement CNN's output, i.e. the value `dense_dim_in_all` must be set to. It
23
+ changes with `n_conv_layers_move` (every layer pools), so it can no longer be
24
+ worked out once and hard-coded. It is derived arithmetically rather than by a
25
+ dummy forward pass, because the answer is needed *before* `ModelParams` is
26
+ built, and it raises if the stack would pool the window away to nothing
27
+ instead of failing later inside `MaxPool2d`.
28
+ - **`deepssf.simulate.trajectory_heatmap`** — count simulated locations per cell
29
+ to give a space-use surface for a simulation. `burn_in` discards the first *n*
30
+ steps of every trajectory, which otherwise pile up around the release site(s)
31
+ and describe the starting conditions more than the fitted model; `agg`
32
+ aggregates *agg* x *agg* raster pixels into one heatmap cell, since at a
33
+ typical 20 m resolution the counts are spread so thinly that nearly every
34
+ occupied cell holds a single location. The returned transform keeps the
35
+ landscape rasters' origin, so the heatmap stays aligned with the layers it was
36
+ simulated on. Locations that walked off the landscape are dropped and reported.
37
+ - **`deepssf.data.save_raster`** — write a 2-D or 3-D array to a GeoTIFF with a
38
+ transform, CRS, nodata value and band descriptions, so anything derived from
39
+ the landscape (a habitat selection surface, a simulation heatmap) opens in
40
+ QGIS/ArcGIS on top of the layers it came from. `int64` is narrowed to `int32`,
41
+ which GeoTIFF supports and `int64` does not.
42
+ - **`deepssf.plot.add_heatmap_overlay`** — add a heatmap raster to an existing
43
+ folium map as its own toggleable layer, log-scaled by default and fully
44
+ transparent below `vmin` so unvisited cells do not grey out the basemap.
45
+ Composes with `plot_trajectories_folium`: that function builds its layer
46
+ control before returning, but folium collects layers at render time, so an
47
+ overlay added afterwards still gets a checkbox. The counts are warped to
48
+ EPSG:3857 with rasterio and handed to folium as a **uint8** image rather than
49
+ using `mercator_project`: folium's own re-projection interpolates the RGBA
50
+ image and returns float64, which `write_png` then rescales channel by channel
51
+ — stretching the colours and flattening the alpha channel to fully opaque.
52
+ - **`deepssf.predict.predict_habitat_landscape`** — run the trained habitat
53
+ filters over an entire raster in one pass, giving a landscape-scale habitat
54
+ selection surface. The habitat sub-network is fully convolutional (3x3
55
+ convolutions, stride 1, padding 1, no pooling or dense layer), so it is
56
+ translation-equivariant: a pixel's value is exactly what the model would
57
+ produce for a window centred on it. The scalar covariates are broadcast to
58
+ full-landscape layers, since `Scalar_to_Grid_Block`'s output size is pinned to
59
+ the training window. Processing is chunked by rows with an `edge_buffer`
60
+ overlap — bit-identical to a single pass, but without the several-hundred-MB
61
+ activations that can exhaust a GPU. `habitat_edge_buffer` reports the exact
62
+ border width that zero-padding contaminates (1 px per conv layer, so 4), which
63
+ is masked to NaN rather than the -inf earlier scripts multiplied through.
64
+ - **Checkpoints record the architecture they were trained with** — a checkpoint
65
+ written from a `ConvJointModel` now carries a `model_params` key holding
66
+ `ModelParams.to_dict()`, alongside the weights. Weights only load into a model
67
+ built with the *same* architecture, so predicting from a saved model
68
+ previously meant re-typing every hyper-parameter into the prediction script,
69
+ where a habitat branch one layer too shallow fails loudly but a mis-set
70
+ `pixel_size` quietly rescales the movement kernel. The key is optional rather
71
+ than a checkpoint-format bump: format-2 readers ignore what they do not know,
72
+ and files written before it existed still load.
73
+ - **`deepssf.train.params_from_checkpoint`** — rebuild the `ModelParams` a
74
+ checkpoint was trained with, ready to pass to `ConvJointModel`. Reads
75
+ `model_params` where present, and otherwise recovers the architecture from the
76
+ layer shapes themselves — a conv weight is `[out_channels, in_channels, k, k]`
77
+ and a linear weight `[out_features, in_features]`, which between them pin down
78
+ every hyper-parameter that changes a tensor shape. Either way the result is
79
+ cross-checked against the saved tensors, so a params dict that contradicts the
80
+ weights it sits beside raises instead of failing later at `load_state_dict`.
81
+ `image_dim` and `pixel_size` leave no trace in the weights and must be given
82
+ for a checkpoint that has no stored params; `image_dim` is then validated
83
+ against the flattened size the movement MLP expects, which pins it to within
84
+ the slack that max-pooling's floor division allows. `device` deliberately
85
+ defaults to this machine rather than to whatever the model was trained on.
86
+ - **`ModelParams.to_dict()`** — a plain-Python copy of the hyper-parameters that
87
+ round-trips through `ModelParams(...)`. Values are coerced to built-in types
88
+ (a `torch.device` or NumPy scalar becomes a `str` or `float`) because
89
+ checkpoints are read back with `torch.load(weights_only=True)`, which accepts
90
+ tensors and built-in containers only. `ModelParams.FIELDS` lists the fields, so
91
+ a new hyper-parameter reaches checkpoints as soon as it is added there.
92
+ - **`EarlyStopping(checkpoint_on=...)`** and **`EarlyStopping(head_paths=...)`**
93
+ — see *Fixed* below.
94
+ - **`load_head_weights`** — copy just one head's weights out of a checkpoint,
95
+ leaving the other head untouched. `HEAD_MODULE_PREFIXES` records the
96
+ module-to-head mapping, mirroring the split `set_trainable` freezes on.
97
+ - **`simulate_trajectories`** — simulate many trajectories in one call, returning
98
+ them stacked in a long-format DataFrame with `trajectory_id` and `step`
99
+ columns. Three methods: `"batched"` (the default), `"sequential"` (a plain loop
100
+ over `simulate_trajectory`) and `"parallel"` (that loop over a thread pool).
101
+ - **`simulate_trajectories_batched`** — steps every trajectory together so the
102
+ model sees a batch of windows per forward pass instead of one. Measured on an
103
+ Apple M-series GPU at `window_size=75`, 200 steps: 2.2× faster than the
104
+ sequential loop at 4 trajectories, 6.9× at 16 and 34× at 64, since the cost per
105
+ step is nearly flat in the number of trajectories. Window extraction is a
106
+ single advanced-indexing gather over a landscape padded once per month, and
107
+ sampling uses `torch.multinomial` over the whole batch. The trade-off is a
108
+ shared clock: `starting_yday`/`starting_hour` must be scalars (start
109
+ *locations* may still vary per trajectory).
110
+ Note that the sub-pixel jitter is scaled from the raster resolution implied by
111
+ the transform, whereas `simulate_next_step` hard-codes a 25 m cell — so batched
112
+ and sequential runs differ slightly on rasters of any other resolution.
113
+ - **`save_trajectories`** — write simulated trajectories to CSV under a name that
114
+ records trajectory count, step count and date, appending a run counter rather
115
+ than ever overwriting an existing file. `split=True` writes one file per
116
+ trajectory.
117
+ - **`deepssf.plot.plot_trajectories_folium`** — draw simulated and observed
118
+ trajectories over a satellite (or topographic/OSM) basemap, reprojecting from
119
+ the data's projected CRS to EPSG:4326 via `rasterio.warp` (no pyproj axis-order
120
+ trap). Each trajectory is its own toggleable layer. `folium` is an optional
121
+ dependency: `pip install "deepSSF[maps]"`.
122
+
123
+ ### Fixed
124
+ - **DataLoaders fail with `affine` 3.x** — `MovementDataset` items carried the
125
+ raster's `Affine` transform as their fifth element, and since `affine` 3.0
126
+ `Affine` is no longer a tuple, so PyTorch's `default_collate` raised a
127
+ `TypeError` on the first batch. The fifth element is now a `[6]` float64
128
+ tensor of the transform's coefficients `(a, b, c, d, e, f)`, which batches on
129
+ any `affine` version; rebuild the transform with `Affine(*coeffs.tolist())`,
130
+ or use `dataset.raster_transform`. The training and validation loops ignore
131
+ this element, so they are unaffected.
132
+ - **Sub-pixel jitter was hard-coded to a 25 m cell.** `simulate_next_step`
133
+ scattered each sampled location by a truncated normal on [0, 25] m regardless
134
+ of the raster's actual resolution. On a 20 m raster that put up to 1.25 cells
135
+ of noise on every simulated step and pushed locations outside the cell the
136
+ model had sampled; on a 100 m raster it never left the first quarter of the
137
+ cell. The cell size now comes from the transform, which is the authority on
138
+ the grid the model was trained against. **Simulated trajectories will differ
139
+ from previous versions on any raster that is not 25 m.**
140
+ - **A habitat-only training stage saved the wrong epoch.** The combined loss is
141
+ `habitat + movement + logZ`, and `logZ = logsumexp(habitat + movement)` — the
142
+ overlap between the habitat surface and the movement kernel — moves with the
143
+ habitat weights. The combined loss and the habitat loss are the same
144
+ used-versus-available contrast measured against different availability sets:
145
+ the combined loss scores habitat against the cells within reach (~109 effective
146
+ cells of a 75x75 window on a feral-pig model), the habitat loss against the
147
+ whole window (~5460). So with movement frozen the combined loss can keep
148
+ falling — habitat discriminating better among near neighbours — while the
149
+ density it gives the observed location gets *worse*. Since checkpointing keyed
150
+ on the combined loss unconditionally, every epoch of such a stage looked like an
151
+ improvement and overwrote the checkpoint, ending on a habitat surface well past
152
+ its best — in one feral-pig run, habitat bottomed out at epoch 12 of a 10-epoch
153
+ stage and then gave back three quarters of what it had learned while the
154
+ combined loss kept falling.
155
+ `EarlyStopping(checkpoint_on='active')` now keys the checkpoint on whichever
156
+ head is training during a single-head stage, falling back to the combined loss
157
+ when both are; `head_paths=` additionally keeps each head's own best epoch in
158
+ its own file. The default remains `'total'` — for the finished *joint* model,
159
+ and for a habitat surface intended as a *selection* map, that is still the
160
+ right criterion; `'active'` is mainly a diagnostic for whether the habitat head
161
+ is learning at all. `negativeLogLikeLoss`'s docstring now spells out what `logZ`
162
+ is, gives the gradients of both criteria, and notes that neither is a spatial
163
+ operation — the habitat CNN has no positional input and is
164
+ translation-equivariant, so the difference between them is entirely in which
165
+ covariate values enter the available side of the contrast.
166
+ - **Environmental layers were not scaled to [0, 1].** `load_environmental_layers`
167
+ divided by the maximum rather than the range — `(data - lo) / hi` instead of
168
+ `(data - lo) / (hi - lo)` — which only lands in [0, 1] when the minimum happens
169
+ to be zero. Sentinel-2 indices stored as ×10 000 (`lo=-10000, hi=10000`) came
170
+ out in [0, 2]; an all-negative layer came out negative *and* axis-flipped,
171
+ reversing the sign of everything the habitat CNN learned from it; a layer with
172
+ `hi == 0` divided by zero. A constant layer now maps to a flat 0 instead of
173
+ raising. **Checkpoints trained on the old scaling will not transfer** — the
174
+ inputs have changed, so retrain.
175
+ - **`conv_movement` was registered with no optimiser.** `make_optimisers` covered
176
+ only `fcn_movement_all` and `conv_habitat`, so the movement CNN trunk received
177
+ gradients on every `backward()` but was never zeroed and never stepped. Its
178
+ `.grad` accumulated monotonically across the whole run: the trunk stayed at its
179
+ random initialisation, and once the buffer overflowed `_grads_are_finite` —
180
+ which iterates over every parameter — began returning `False`, silently
181
+ skipping *every* subsequent update, habitat's included. `conv_movement` now
182
+ sits in the movement optimiser alongside `fcn_movement_all`.
183
+ - `train_loop` zeroes gradients with `model.zero_grad(set_to_none=True)` rather
184
+ than per-optimiser, so no parameter can be left out of the reset however the
185
+ optimisers are configured. The manual stash-and-restore of habitat gradients
186
+ around the two optimiser steps has been removed: the optimisers own disjoint
187
+ parameter sets and `Adam.step()` only reads `.grad` for its own parameters, so
188
+ it was a no-op.
189
+ - **Both learning-rate schedulers stepped on the combined validation loss.** That
190
+ loss is `habitat + movement + logZ` and is dominated by the movement component
191
+ (~1.2 nats of movement against ~0.05 of habitat over a typical run), so the
192
+ habitat learning rate was being cut whenever the *movement* head plateaued.
193
+ Each scheduler now steps on its own component, and only while that component is
194
+ training — stepping a frozen head's scheduler would penalise it for an
195
+ improvement it was never given the chance to make.
196
+ - **NaN movement probability surfaces during training.** In float32 the von
197
+ Mises normaliser `I₀(κ)` overflows to `inf` at κ ≈ 89 — reachable from a raw
198
+ FCN output of only `log(89) ≈ 4.5`. With both mixture components overflowing,
199
+ the log-sum-exp evaluated `-inf - (-inf)` and the entire surface became NaN;
200
+ with only one overflowing, the forward pass stayed finite but produced NaN
201
+ gradients that silently propagated into every weight on the next optimiser
202
+ step. `Params_to_Grid_Block._vonmises_log` now uses the exponentially-scaled
203
+ Bessel function, `log I₀(κ) = κ + log(i0e(κ))`, giving
204
+ `κ·(cos(θ-μ) - 1) - log(2π) - log(i0e(κ))`, whose exponent is ≤ 0 for every κ.
205
+ - Mixture components are combined with `torch.logsumexp` instead of a manual
206
+ max/exp/log, which returned NaN when both components were `-inf`.
207
+ - Raw FCN outputs are clamped (`Params_to_Grid_Block.raw_clamp`, default 10.0)
208
+ before exponentiation. Previously `exp` overflowed to `inf` at a raw value of
209
+ ≈ 88, making `lgamma(inf) - inf` NaN, and scale underflowed to 0 at ≈ -88.
210
+ - `torch.special.i0e` is called on the active device. The previous `.cpu()`
211
+ round-trip for MPS forced a host sync on every forward pass; `i0`/`i0e` now
212
+ have CPU, CUDA and MPS kernels registered (verified on torch 2.12).
213
+ - `distance_layer` and `bearing_layer` are registered buffers, so `model.to()`
214
+ moves them once instead of copying them host→device on every forward pass.
215
+ They use `persistent=False`: both are constants derived from `image_dim` and
216
+ `pixel_size`, so they stay out of `state_dict` and checkpoint keys are
217
+ unchanged.
218
+ - `train_loop` skips batches whose loss or gradients are not finite rather than
219
+ stepping the optimiser on them, and excludes them from the epoch mean. A
220
+ single bad batch no longer makes a run unrecoverable.
221
+ - The epoch mean is now averaged over contributing batches, not `num_batches`.
222
+ - Corrected the `Params_to_Grid_Block_ChV` docstring: it subtracts `log(r)` from
223
+ the log-density (dividing the density by `r`), not "by `log(r)`".
224
+
225
+ ### Changed
226
+ - **Breaking:** `skip_epoch0_training` has been removed from `train_loop`. It had
227
+ no epoch awareness — it was a per-call switch that `fit` never passed, so its
228
+ default applied to *every* epoch, and with that default at `True` an entire run
229
+ executed forward-only under `torch.set_grad_enabled(False)`. It also measured a
230
+ training-set baseline when the useful number is a validation one. `fit` now runs
231
+ one validation pass before the first epoch and records it at index 0 of every
232
+ history list, with `train_losses[0] = nan`.
233
+ - `history` gains a `stage` key recording which stage each epoch belonged to
234
+ (`-1` for the pre-training baseline row).
235
+ - **Breaking (checkpoints):** mixture weights are now `log_softmax` over the raw
236
+ FCN outputs. Previously `softmax(exp(raw))` was applied — a double exponential
237
+ that saturated to exactly `(1, 0)` by a raw value of ≈ 4.75, zeroing the weight
238
+ gradient and putting `log(0) = -inf` into the surface. Movement parameters
239
+ 2, 5, 8 and 11 therefore have a different meaning, so checkpoints written by
240
+ ≤ 0.2.3 will be reinterpreted rather than failing to load. The step-length and
241
+ turning-angle densities themselves are unchanged (agreement to ~6e-6 when the
242
+ two weights are equal).
243
+ - `negativeLogLikeLoss` raises a `RuntimeWarning` once per non-finite surface
244
+ instead of printing on every batch, and performs one device sync per batch
245
+ rather than three.
246
+ - `_expand` uses `expand` rather than `repeat`, returning a view instead of
247
+ materialising a `[B, H, W]` copy per parameter.
248
+
249
+ ## [0.2.3] - 2026-05-31
250
+
251
+ ### Added
252
+ - **`EarlyStopping(monitor='both')`.** Keeps a separate patience budget for the
253
+ habitat and movement components and stops only once every active component has
254
+ plateaued, so a converged movement head can no longer end a run in which habitat
255
+ is still improving. Checkpointing still keys on the combined loss — the joint
256
+ likelihood remains the right model-selection criterion; only the stopping rule
257
+ changes. `reset()` clears the counters at stage boundaries while leaving the
258
+ best score and saved checkpoint intact.
259
+ - **`fit(stages=...)`** runs the sub-networks as a coordinate ascent on the same
260
+ joint likelihood, e.g. movement alone to establish an availability kernel, then
261
+ habitat alone to its own convergence, then both. A frozen branch keeps
262
+ contributing its surface to the loss as a fixed offset; only its weights stop
263
+ moving, so the objective is unchanged rather than reweighted. This is not what
264
+ `negativeLogLikeLoss(freeze_movement=True)` does — that flag *drops* the movement
265
+ surface from the loss, which would make the habitat head learn a distance-decay
266
+ bump in order to explain the movement structure itself. Early stopping ends the
267
+ current stage and advances to the next. `reset_optimiser_state` (default `True`)
268
+ clears the Adam moments of a branch when it becomes active again.
269
+ - `set_trainable(model, *, habitat, movement)` toggles `requires_grad` per
270
+ sub-network. Exported from the package root.
271
+ - See `Habitat_movement_loss_balancing.md` for why the fix for the
272
+ habitat/movement asymmetry is a monitoring change rather than a loss weight.
273
+ - **Versioned checkpoints.** `EarlyStopping` now writes a dict with
274
+ `deepssf_checkpoint_format`, `deepssf_version`, `val_loss` and `state_dict`
275
+ keys instead of a bare `state_dict` (`CHECKPOINT_FORMAT = 2`). Because the
276
+ `state_dict` keys themselves did not change, a pre-0.3.0 file would otherwise
277
+ load without error while silently misreading movement parameters 2, 5, 8
278
+ and 11 — the marker makes that case fail loudly.
279
+ - `load_checkpoint(path, model=None, *, map_location=None, allow_legacy=False,
280
+ strict=True)` reads a checkpoint, verifies its format, and optionally loads it
281
+ into a model. Legacy (format 1) files raise `RuntimeError` unless
282
+ `allow_legacy=True`; files from a future format are refused with an
283
+ upgrade message. Exported from the package root alongside `CHECKPOINT_FORMAT`.
284
+ - `grad_clip` argument on `fit` and `train_loop` for global gradient-norm
285
+ clipping (`None` by default, so existing behaviour is unchanged).
286
+ - Regression tests covering forward and backward finiteness at extreme movement
287
+ parameters, log-normalisation of the surface, and non-vanishing mixture-weight
288
+ gradients.
289
+ - Initial package scaffold.
@@ -0,0 +1,237 @@
1
+ # Balancing the habitat and movement components of the deepSSF loss
2
+
3
+ A design record for the training changes introduced after 0.3.0. It explains why
4
+ the habitat sub-network appeared not to learn, why the fix is not a loss weight,
5
+ and what the `stages` / `monitor="both"` machinery is for.
6
+
7
+ ## The symptom
8
+
9
+ In a joint model trained on feral pig GPS data, the movement component of the
10
+ validation loss fell by roughly 1.2 nats over a run while the habitat component
11
+ moved by about 0.05 and sometimes drifted upward. On a 75×75 window a uniform
12
+ surface scores `log(75 × 75) = 8.635` nats; the habitat head finished at ≈ 8.25,
13
+ i.e. it had extracted about 0.4 nats of structure in total. Scoring the observed
14
+ next step on held-out data told the same story: mean habitat probability 0.000206
15
+ against a uniform 1/5625 = 0.000178, versus a mean movement probability of 0.054.
16
+
17
+ The natural reading is that the two components are summed into one loss and the
18
+ larger one drowns out the smaller, so the fix must be a weight on the habitat
19
+ term. That reading is wrong in an instructive way.
20
+
21
+ ## What the loss actually is
22
+
23
+ `ConvJointModel` returns a `[B, H, W, 2]` stack in which **both** slices are
24
+ already log-normalised over the window — the habitat head subtracts its own
25
+ `logsumexp` (`Conv2d_block_spatial.forward`), and so does the movement head
26
+ (`Params_to_Grid_Block.forward`). `negativeLogLikeLoss` then sums them and
27
+ renormalises the sum:
28
+
29
+ ```python
30
+ pred_prod = hab_surface + move_surface
31
+ pred_prod = pred_prod - torch.logsumexp(pred_prod, dim=(1, 2), keepdim=True)
32
+ nll = -pred_prod[batch_idx, py2, px2]
33
+ ```
34
+
35
+ The `habitat_loss` and `movement_loss` it also returns are **diagnostics**:
36
+ `train_loop` and `test_loop` discard them (`total_loss, _, _ = loss_fn(...)`), and
37
+ only the epoch summary in `fit` reports them. There is no weighted sum anywhere,
38
+ and therefore no λ to turn.
39
+
40
+ Because both surfaces arrive normalised, the total decomposes exactly:
41
+
42
+ ```
43
+ total = habitat_nll + movement_nll + logZ, logZ = logsumexp(hab + mov) ≤ 0
44
+ ```
45
+
46
+ So the *magnitudes* really are dominated by movement. But the gradient tells a
47
+ different story. Differentiating the total with respect to the habitat
48
+ parameters gives
49
+
50
+ ```
51
+ ∂total/∂θ_hab = -∇hab(y) + E_{p_combined}[∇hab]
52
+ ```
53
+
54
+ — the standard conditional-logistic form: observed minus its expectation under
55
+ the combined surface. That is exactly the right step-selection gradient, with the
56
+ movement kernel playing the role of the availability distribution. **The habitat
57
+ head was never starved of gradient.** Adam normalises by gradient RMS on top of
58
+ that, so the raw scale difference between the components does not translate into
59
+ a proportional difference in step size either.
60
+
61
+ ### Why weighting the habitat term would be wrong
62
+
63
+ The obvious intervention — minimise `total + λ · habitat_nll` — has a specific
64
+ failure mode. `habitat_nll` is the NLL of the habitat surface *on its own*, over
65
+ the whole window, and it is minimised by putting probability mass where animals
66
+ actually end up, which is overwhelmingly near the centre pixel. A habitat head
67
+ trained against it learns a distance-decay bump centred on the previous location:
68
+ it absorbs the movement structure instead of describing resource selection. The
69
+ same objection applies to `negativeLogLikeLoss(freeze_movement=True)`, which drops
70
+ the movement surface from the combined loss entirely — despite its name, it is not
71
+ a way to hold movement fixed while habitat trains. **Do not use it for that.**
72
+
73
+ ## Where the asymmetry actually bites: model selection
74
+
75
+ The damage is downstream of the gradient. Before this change, both
76
+ `ReduceLROnPlateau` schedulers and `EarlyStopping` stepped on the combined
77
+ validation loss. Since that loss moves ~1.2 nats on movement and ~0.05 on
78
+ habitat, habitat's genuine improvement sits *inside* movement's epoch-to-epoch
79
+ noise band. The consequences:
80
+
81
+ - The run early-stops on movement's schedule. Habitat is still improving, but the
82
+ combined loss has stopped moving by more than noise, so the patience counter
83
+ climbs and training ends.
84
+ - The habitat learning rate is cut when the *movement* head plateaus.
85
+
86
+ Neither is fixed by changing the objective. Both are fixed by changing what is
87
+ monitored.
88
+
89
+ ## Two bugs found alongside it
90
+
91
+ Neither is about loss balance, but no balancing scheme could be evaluated while
92
+ they stood.
93
+
94
+ **`conv_movement` belonged to no optimiser.** `make_optimisers` registered only
95
+ `fcn_movement_all` and `conv_habitat`. The movement CNN trunk received gradients
96
+ on every `backward()` but was never zeroed and never stepped, so its `.grad`
97
+ accumulated monotonically across the entire run. Two consequences: the trunk
98
+ stayed at its random initialisation forever, so the movement head was reading
99
+ fixed random features; and once the accumulating buffer overflowed,
100
+ `_grads_are_finite(model)` — which iterates over *every* parameter — began
101
+ returning `False`, at which point **every** subsequent update was silently
102
+ skipped, habitat's included, with only a `non-finite gradients at batch N`
103
+ warning to show for it.
104
+
105
+ **`skip_epoch0_training` disabled training.** The flag had no epoch awareness: it
106
+ was a per-call switch, and `fit` never passed it, so whatever its default was
107
+ applied to every epoch. With the default at `True`, the entire run executed
108
+ forward-only under `torch.set_grad_enabled(False)`. It also measured a
109
+ *training-set* baseline, when the number worth having is a validation one.
110
+
111
+ ## The changes
112
+
113
+ ### 1. Correct parameter ownership
114
+
115
+ `make_optimisers` now puts `conv_movement` and `fcn_movement_all` in the movement
116
+ optimiser as two param groups. `train_loop` zeroes gradients with
117
+ `model.zero_grad(set_to_none=True)` rather than per-optimiser, so no parameter can
118
+ fall outside the reset regardless of how the optimisers are configured.
119
+
120
+ The manual stash-and-restore of habitat gradients that used to bracket the two
121
+ optimiser steps has been deleted. The two optimisers own disjoint parameter sets
122
+ and `Adam.step()` only reads `.grad` for its own parameters, so clearing habitat
123
+ gradients before the movement step and putting them back afterwards was a no-op.
124
+
125
+ A regression test asserts that every parameter in `model.parameters()` is owned by
126
+ exactly one optimiser.
127
+
128
+ ### 2. Per-head monitoring
129
+
130
+ Each `ReduceLROnPlateau` now steps on its own component of the validation loss,
131
+ and only while that component is training — stepping a frozen head's scheduler
132
+ would cut its learning rate for a lack of improvement it was never given the
133
+ chance to make.
134
+
135
+ `EarlyStopping` gains `monitor="both"`, which keeps a separate patience budget per
136
+ component and stops only once *every active* component has plateaued. Checkpointing
137
+ is deliberately unchanged: it still keys on the combined validation loss, because
138
+ the joint likelihood remains the correct model-selection criterion. Only the
139
+ stopping rule changes. `reset()` clears the counters at stage boundaries while
140
+ leaving the best score and saved checkpoint intact.
141
+
142
+ Note that with `delta=0.0` an exactly-unchanged loss counts as an improvement.
143
+ This is pre-existing behaviour, retained; real losses fluctuate, so a genuine
144
+ plateau still accumulates patience.
145
+
146
+ ### 3. Staged (coordinate) training
147
+
148
+ `fit(stages=[...])` runs the sub-networks in turn:
149
+
150
+ ```python
151
+ stages=[
152
+ {"epochs": 10, "train": ("movement",)},
153
+ {"epochs": 40, "train": ("habitat",)},
154
+ {"epochs": 20, "train": ("habitat", "movement")},
155
+ ]
156
+ ```
157
+
158
+ Movement goes first so that a sensible availability kernel is in place before
159
+ habitat is fitted conditional on it. Habitat then runs to its own convergence on
160
+ its own early-stopping clock. A joint stage settles both.
161
+
162
+ A frozen branch is frozen through `set_trainable` (`requires_grad=False`) *and* by
163
+ withholding its optimiser. Crucially, **its surface stays in the likelihood as a
164
+ fixed offset** — only its weights stop moving. That is what makes this coordinate
165
+ ascent on the true joint objective rather than a reweighting of it, and it is the
166
+ difference between this mechanism and `freeze_movement=True`.
167
+
168
+ `reset_optimiser_state` (default `True`) clears the Adam moments of a branch when
169
+ it becomes active in a new stage, so a head frozen for forty epochs does not
170
+ resume on momentum pointing in a long-outdated direction.
171
+
172
+ Early stopping ends the current *stage* and advances to the next; for the final
173
+ stage that ends the run, so the behaviour is uniform.
174
+
175
+ ### 4. A pre-training baseline
176
+
177
+ `skip_epoch0_training` is removed. `fit` instead runs one validation pass before
178
+ the first epoch and records it at index 0 of every history list, with
179
+ `train_losses[0] = nan` and `stage[0] = -1`. An untrained habitat surface is
180
+ uniform, so the baseline should land near `log(H × W)`; the gap between that and
181
+ the final `val_habitat_losses` is a direct, unambiguous read on how much the
182
+ habitat head actually learned. `history` also gains a `stage` key recording which
183
+ stage each epoch belonged to.
184
+
185
+ ## Verifying it on a run
186
+
187
+ - The baseline line should print before epoch 1 with `hab ≈ log(H × W)`.
188
+ - No `non-finite gradients at batch N` warnings should appear — those were the
189
+ signature of the accumulating `conv_movement` gradients.
190
+ - In a movement-only stage `val_habitat_losses` must be exactly flat, and in a
191
+ habitat-only stage `val_movement_losses` must be exactly flat. If either moves,
192
+ the freeze is not working.
193
+ - `val_habitat_losses` should fall materially below `log(H × W)` and flatten
194
+ *before* the run ends, rather than the run ending first.
195
+
196
+ ### 5. Correct covariate scaling
197
+
198
+ Found while investigating the flat habitat surface and fixed alongside it, since
199
+ it feeds the habitat CNN directly. `load_environmental_layers` divided by the
200
+ maximum rather than the range:
201
+
202
+ ```python
203
+ data = (data - lo) / hi # only correct when lo == 0
204
+ ```
205
+
206
+ The failure is sign-dependent, which is why it went unnoticed:
207
+
208
+ | layer range | old result | correct |
209
+ |---|---|---|
210
+ | `lo=0, hi=100` | `[0, 1]` | `[0, 1]` — right by accident |
211
+ | `lo=-10000, hi=10000` (S2 indices ×10 000) | `[0, 2]` | `[0, 1]` |
212
+ | `lo=-100, hi=-10` (all negative) | `[0, -9]` — negative *and* axis-flipped | `[0, 1]` |
213
+ | `hi = 0` | `ZeroDivisionError` | `[0, 1]` |
214
+
215
+ The axis flip is the serious one: it reverses the sign of everything the habitat
216
+ CNN learns from that layer. The denominator is now `hi - lo`, with a constant
217
+ layer mapping to a flat 0 rather than dividing by zero.
218
+
219
+ Out-of-window padding stays at `-1.0`. With inputs correctly in `[0, 1]` that is
220
+ one full data range below the minimum, so padding remains cleanly distinguishable
221
+ from real values — which is what `image_trim_pixels` exists to keep out of the
222
+ snapshot visualisations.
223
+
224
+ **Checkpoints trained on the old scaling will not transfer.** The inputs have
225
+ changed; retrain.
226
+
227
+ ## Still open
228
+
229
+ Found while investigating this, not addressed by it.
230
+
231
+ - **Habitat capacity.** Four conv layers at `output_channels=4` is ~27k parameters
232
+ for the entire model, which may simply be too little to express much spatial
233
+ structure. Worth revisiting now that the scaling is right and the training
234
+ dynamics are trustworthy.
235
+ - **Per-band vs joint scaling.** A multi-band TIFF is scaled by its global min and
236
+ max, so relative magnitudes between bands survive. That is right for bands
237
+ sharing a natural range (NDVI/NDWI/NDMI) and wrong for bands in unrelated units.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: deepSSF
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Deep learning step selection functions for predicting animal movement.
5
5
  Project-URL: Homepage, https://swforrest.github.io/deepSSF/
6
6
  Project-URL: Repository, https://github.com/swforrest/deepssf
@@ -35,6 +35,8 @@ Requires-Dist: twine>=5.0; extra == 'dev'
35
35
  Provides-Extra: examples
36
36
  Requires-Dist: ipykernel>=6.0; extra == 'examples'
37
37
  Requires-Dist: jupyterlab>=4.0; extra == 'examples'
38
+ Provides-Extra: maps
39
+ Requires-Dist: folium>=0.15; extra == 'maps'
38
40
  Description-Content-Type: text/markdown
39
41
 
40
42
  # deepSSF