deepSSF 0.2.3__tar.gz → 0.3.1__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.
- deepssf-0.3.1/CHANGELOG.md +334 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/CITATION.cff +1 -1
- deepssf-0.3.1/Habitat_movement_loss_balancing.md +237 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/PKG-INFO +4 -2
- deepssf-0.3.1/examples/deepssf_train_validate_example_v0_3_0.ipynb +2964 -0
- deepssf-0.3.1/examples/deepssf_train_validate_example_v0_3_1.ipynb +2982 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/pyproject.toml +5 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/__init__.py +33 -2
- {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/data.py +222 -65
- deepssf-0.3.1/src/deepssf/model.py +592 -0
- deepssf-0.3.1/src/deepssf/plot.py +420 -0
- deepssf-0.3.1/src/deepssf/predict.py +178 -0
- deepssf-0.3.1/src/deepssf/simulate.py +983 -0
- deepssf-0.3.1/src/deepssf/train.py +1596 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/utils.py +27 -2
- {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/validate.py +13 -5
- deepssf-0.3.1/tests/test_deepssf.py +2717 -0
- deepssf-0.2.3/CHANGELOG.md +0 -10
- deepssf-0.2.3/examples/deepssf_train_validate_example.ipynb +0 -2523
- deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-15-output-1.png +0 -0
- deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-17-output-1.png +0 -0
- deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-19-output-1.png +0 -0
- deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-21-output-1.png +0 -0
- deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-23-output-1.png +0 -0
- deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-24-output-1.png +0 -0
- deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-27-output-1.png +0 -0
- deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-28-output-1.png +0 -0
- deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-30-output-1.png +0 -0
- deepssf-0.2.3/src/deepssf/model.py +0 -362
- deepssf-0.2.3/src/deepssf/simulate.py +0 -313
- deepssf-0.2.3/src/deepssf/train.py +0 -576
- deepssf-0.2.3/tests/test_deepssf.py +0 -842
- {deepssf-0.2.3 → deepssf-0.3.1}/.github/workflows/ci.yml +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/.github/workflows/publish.yml +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/.gitignore +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/.pre-commit-config.yaml +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/LICENSE +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/Package_generation_process.md +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/README.md +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/environment.yml +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example.html +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/figure-html/cell-25-output-1.png +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/figure-html/cell-29-output-1.png +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/figure-html/cell-32-output-1.png +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/figure-html/cell-7-output-1.png +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap-138a6193a3bd40baf1e627da441a4734.min.css +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap-icons.css +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap-icons.woff +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap.min.js +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/clipboard/clipboard.min.js +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/anchor.min.js +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/popper.min.js +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/quarto-syntax-highlighting-7f8f88aac4f3542376d5c11b86a4c14d.css +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/quarto.js +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/tabsets/tabsets.js +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/tippy.css +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/tippy.umd.min.js +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/examples/references.bib +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/icons/both_icons.png +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/icons/deepSSF_hex_textured_Python.png +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/icons/deepSSF_hex_textured_R.png +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/datasets/data/buffalo_djelk_id2005.csv +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/datasets/data/ndvi_2005.tif +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/datasets/data/slope_2005.tif +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/styles.css +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/tests/__init__.py +0 -0
- {deepssf-0.2.3 → deepssf-0.3.1}/tests/test_smoke.py +0 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.3.1] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Step length and turning angle columns** — `prepare_movement_df` now returns
|
|
11
|
+
`sl_` (step length, the Euclidean length of `(dx, dy)`, placed after `dx`/`dy`)
|
|
12
|
+
and `ta_` (turning angle, placed after `bearing_tm1`). `ta_` is
|
|
13
|
+
`bearing - bearing_tm1` wrapped to [-π, π), so a slight turn across the ±π
|
|
14
|
+
boundary is not reported as a near-full rotation; positive values are
|
|
15
|
+
anticlockwise turns, following the `arctan2` bearing convention. It is NaN for
|
|
16
|
+
each individual's first step, which has no previous bearing. Neither column is
|
|
17
|
+
a model input.
|
|
18
|
+
- **`tz` argument to `prepare_movement_df`** — the timezone to express
|
|
19
|
+
timestamps and time-derived columns in, e.g. `"Australia/Darwin"`. When
|
|
20
|
+
omitted, the timezone of a tz-aware time column is kept; tz-naive times are
|
|
21
|
+
still treated as UTC.
|
|
22
|
+
- **Timestamps on simulated trajectories** — `simulate_trajectory`,
|
|
23
|
+
`simulate_trajectories_batched` and `simulate_trajectories` take an optional
|
|
24
|
+
`start_time` and, when it is given, add a `time` column (before `hour`) rolled
|
|
25
|
+
forward from it by `time_between_steps`. Rolling forward rather than rebuilding
|
|
26
|
+
from `yday`/`hour` keeps the year (which `yday` loses when it wraps at 365)
|
|
27
|
+
and the timezone of `start_time`. It only labels the output — the model still
|
|
28
|
+
reads `starting_yday`/`starting_hour`, so these should describe the same
|
|
29
|
+
moment. `simulate_trajectories` accepts one start time per trajectory under
|
|
30
|
+
`method="sequential"`/`"parallel"`; `"batched"` requires a single value, as it
|
|
31
|
+
does for the other start-time arguments.
|
|
32
|
+
- **Example notebook `deepssf_train_validate_example_v0_3_1.ipynb`** — adds
|
|
33
|
+
diagnostic summaries of the observed and simulated data: step length and
|
|
34
|
+
turning angle histograms, and both by hour of day. Turning angles are
|
|
35
|
+
summarised per hour by the circular SD and the shortest arc containing 50% of
|
|
36
|
+
the angles (which may wrap across ±π), since a mean or median sits near zero
|
|
37
|
+
whatever the spread.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- **Times were converted to UTC in `prepare_movement_df`.** Timestamps were
|
|
41
|
+
parsed with `utc=True` and the input's timezone was never restored, so a
|
|
42
|
+
tz-aware time column (e.g. converted to `Australia/Darwin`) still produced
|
|
43
|
+
`hour_t1`, `yday_t1` and their sine/cosine encodings in UTC — 9.5 h out for
|
|
44
|
+
Darwin, misplacing diel patterns such as dawn and dusk activity peaks. These
|
|
45
|
+
columns are now in local time, and `t1_`/`t2_` are tz-aware timestamps
|
|
46
|
+
rather than the raw input values. The Sentinel-2 month look-up in
|
|
47
|
+
`MovementDataset`, which reads `t1_`, now uses the local month too.
|
|
48
|
+
**Models trained with ≤ 0.3.0 on tz-aware times learned UTC hour encodings**,
|
|
49
|
+
so retrain them (and re-run any simulations) before comparing against data
|
|
50
|
+
prepared with this version.
|
|
51
|
+
|
|
52
|
+
## [0.3.0] - 2026-10-23
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- **Configurable convolutional depth** — `ModelParams` now takes
|
|
56
|
+
`n_conv_layers_hab` and `n_conv_layers_move`, setting the depth of the two
|
|
57
|
+
sub-networks independently. The habitat count includes the final convolution
|
|
58
|
+
that projects to one output channel (so the minimum is 1), and since that
|
|
59
|
+
block does no pooling, extra layers only deepen the features and widen the
|
|
60
|
+
receptive field. The movement count is the number of conv → ReLU → max-pool
|
|
61
|
+
blocks, so it also controls how far the window is reduced before the
|
|
62
|
+
fully-connected layers. Both keys are optional and default to the paper
|
|
63
|
+
architecture (4 and 3), so existing params dicts and checkpoints are
|
|
64
|
+
unaffected — but note that a checkpoint only loads into a model with the same
|
|
65
|
+
layer counts it was trained with.
|
|
66
|
+
- **`deepssf.model.flattened_conv_dim`** — compute the flattened size of the
|
|
67
|
+
movement CNN's output, i.e. the value `dense_dim_in_all` must be set to. It
|
|
68
|
+
changes with `n_conv_layers_move` (every layer pools), so it can no longer be
|
|
69
|
+
worked out once and hard-coded. It is derived arithmetically rather than by a
|
|
70
|
+
dummy forward pass, because the answer is needed *before* `ModelParams` is
|
|
71
|
+
built, and it raises if the stack would pool the window away to nothing
|
|
72
|
+
instead of failing later inside `MaxPool2d`.
|
|
73
|
+
- **`deepssf.simulate.trajectory_heatmap`** — count simulated locations per cell
|
|
74
|
+
to give a space-use surface for a simulation. `burn_in` discards the first *n*
|
|
75
|
+
steps of every trajectory, which otherwise pile up around the release site(s)
|
|
76
|
+
and describe the starting conditions more than the fitted model; `agg`
|
|
77
|
+
aggregates *agg* x *agg* raster pixels into one heatmap cell, since at a
|
|
78
|
+
typical 20 m resolution the counts are spread so thinly that nearly every
|
|
79
|
+
occupied cell holds a single location. The returned transform keeps the
|
|
80
|
+
landscape rasters' origin, so the heatmap stays aligned with the layers it was
|
|
81
|
+
simulated on. Locations that walked off the landscape are dropped and reported.
|
|
82
|
+
- **`deepssf.data.save_raster`** — write a 2-D or 3-D array to a GeoTIFF with a
|
|
83
|
+
transform, CRS, nodata value and band descriptions, so anything derived from
|
|
84
|
+
the landscape (a habitat selection surface, a simulation heatmap) opens in
|
|
85
|
+
QGIS/ArcGIS on top of the layers it came from. `int64` is narrowed to `int32`,
|
|
86
|
+
which GeoTIFF supports and `int64` does not.
|
|
87
|
+
- **`deepssf.plot.add_heatmap_overlay`** — add a heatmap raster to an existing
|
|
88
|
+
folium map as its own toggleable layer, log-scaled by default and fully
|
|
89
|
+
transparent below `vmin` so unvisited cells do not grey out the basemap.
|
|
90
|
+
Composes with `plot_trajectories_folium`: that function builds its layer
|
|
91
|
+
control before returning, but folium collects layers at render time, so an
|
|
92
|
+
overlay added afterwards still gets a checkbox. The counts are warped to
|
|
93
|
+
EPSG:3857 with rasterio and handed to folium as a **uint8** image rather than
|
|
94
|
+
using `mercator_project`: folium's own re-projection interpolates the RGBA
|
|
95
|
+
image and returns float64, which `write_png` then rescales channel by channel
|
|
96
|
+
— stretching the colours and flattening the alpha channel to fully opaque.
|
|
97
|
+
- **`deepssf.predict.predict_habitat_landscape`** — run the trained habitat
|
|
98
|
+
filters over an entire raster in one pass, giving a landscape-scale habitat
|
|
99
|
+
selection surface. The habitat sub-network is fully convolutional (3x3
|
|
100
|
+
convolutions, stride 1, padding 1, no pooling or dense layer), so it is
|
|
101
|
+
translation-equivariant: a pixel's value is exactly what the model would
|
|
102
|
+
produce for a window centred on it. The scalar covariates are broadcast to
|
|
103
|
+
full-landscape layers, since `Scalar_to_Grid_Block`'s output size is pinned to
|
|
104
|
+
the training window. Processing is chunked by rows with an `edge_buffer`
|
|
105
|
+
overlap — bit-identical to a single pass, but without the several-hundred-MB
|
|
106
|
+
activations that can exhaust a GPU. `habitat_edge_buffer` reports the exact
|
|
107
|
+
border width that zero-padding contaminates (1 px per conv layer, so 4), which
|
|
108
|
+
is masked to NaN rather than the -inf earlier scripts multiplied through.
|
|
109
|
+
- **Checkpoints record the architecture they were trained with** — a checkpoint
|
|
110
|
+
written from a `ConvJointModel` now carries a `model_params` key holding
|
|
111
|
+
`ModelParams.to_dict()`, alongside the weights. Weights only load into a model
|
|
112
|
+
built with the *same* architecture, so predicting from a saved model
|
|
113
|
+
previously meant re-typing every hyper-parameter into the prediction script,
|
|
114
|
+
where a habitat branch one layer too shallow fails loudly but a mis-set
|
|
115
|
+
`pixel_size` quietly rescales the movement kernel. The key is optional rather
|
|
116
|
+
than a checkpoint-format bump: format-2 readers ignore what they do not know,
|
|
117
|
+
and files written before it existed still load.
|
|
118
|
+
- **`deepssf.train.params_from_checkpoint`** — rebuild the `ModelParams` a
|
|
119
|
+
checkpoint was trained with, ready to pass to `ConvJointModel`. Reads
|
|
120
|
+
`model_params` where present, and otherwise recovers the architecture from the
|
|
121
|
+
layer shapes themselves — a conv weight is `[out_channels, in_channels, k, k]`
|
|
122
|
+
and a linear weight `[out_features, in_features]`, which between them pin down
|
|
123
|
+
every hyper-parameter that changes a tensor shape. Either way the result is
|
|
124
|
+
cross-checked against the saved tensors, so a params dict that contradicts the
|
|
125
|
+
weights it sits beside raises instead of failing later at `load_state_dict`.
|
|
126
|
+
`image_dim` and `pixel_size` leave no trace in the weights and must be given
|
|
127
|
+
for a checkpoint that has no stored params; `image_dim` is then validated
|
|
128
|
+
against the flattened size the movement MLP expects, which pins it to within
|
|
129
|
+
the slack that max-pooling's floor division allows. `device` deliberately
|
|
130
|
+
defaults to this machine rather than to whatever the model was trained on.
|
|
131
|
+
- **`ModelParams.to_dict()`** — a plain-Python copy of the hyper-parameters that
|
|
132
|
+
round-trips through `ModelParams(...)`. Values are coerced to built-in types
|
|
133
|
+
(a `torch.device` or NumPy scalar becomes a `str` or `float`) because
|
|
134
|
+
checkpoints are read back with `torch.load(weights_only=True)`, which accepts
|
|
135
|
+
tensors and built-in containers only. `ModelParams.FIELDS` lists the fields, so
|
|
136
|
+
a new hyper-parameter reaches checkpoints as soon as it is added there.
|
|
137
|
+
- **`EarlyStopping(checkpoint_on=...)`** and **`EarlyStopping(head_paths=...)`**
|
|
138
|
+
— see *Fixed* below.
|
|
139
|
+
- **`load_head_weights`** — copy just one head's weights out of a checkpoint,
|
|
140
|
+
leaving the other head untouched. `HEAD_MODULE_PREFIXES` records the
|
|
141
|
+
module-to-head mapping, mirroring the split `set_trainable` freezes on.
|
|
142
|
+
- **`simulate_trajectories`** — simulate many trajectories in one call, returning
|
|
143
|
+
them stacked in a long-format DataFrame with `trajectory_id` and `step`
|
|
144
|
+
columns. Three methods: `"batched"` (the default), `"sequential"` (a plain loop
|
|
145
|
+
over `simulate_trajectory`) and `"parallel"` (that loop over a thread pool).
|
|
146
|
+
- **`simulate_trajectories_batched`** — steps every trajectory together so the
|
|
147
|
+
model sees a batch of windows per forward pass instead of one. Measured on an
|
|
148
|
+
Apple M-series GPU at `window_size=75`, 200 steps: 2.2× faster than the
|
|
149
|
+
sequential loop at 4 trajectories, 6.9× at 16 and 34× at 64, since the cost per
|
|
150
|
+
step is nearly flat in the number of trajectories. Window extraction is a
|
|
151
|
+
single advanced-indexing gather over a landscape padded once per month, and
|
|
152
|
+
sampling uses `torch.multinomial` over the whole batch. The trade-off is a
|
|
153
|
+
shared clock: `starting_yday`/`starting_hour` must be scalars (start
|
|
154
|
+
*locations* may still vary per trajectory).
|
|
155
|
+
Note that the sub-pixel jitter is scaled from the raster resolution implied by
|
|
156
|
+
the transform, whereas `simulate_next_step` hard-codes a 25 m cell — so batched
|
|
157
|
+
and sequential runs differ slightly on rasters of any other resolution.
|
|
158
|
+
- **`save_trajectories`** — write simulated trajectories to CSV under a name that
|
|
159
|
+
records trajectory count, step count and date, appending a run counter rather
|
|
160
|
+
than ever overwriting an existing file. `split=True` writes one file per
|
|
161
|
+
trajectory.
|
|
162
|
+
- **`deepssf.plot.plot_trajectories_folium`** — draw simulated and observed
|
|
163
|
+
trajectories over a satellite (or topographic/OSM) basemap, reprojecting from
|
|
164
|
+
the data's projected CRS to EPSG:4326 via `rasterio.warp` (no pyproj axis-order
|
|
165
|
+
trap). Each trajectory is its own toggleable layer. `folium` is an optional
|
|
166
|
+
dependency: `pip install "deepSSF[maps]"`.
|
|
167
|
+
|
|
168
|
+
### Fixed
|
|
169
|
+
- **DataLoaders fail with `affine` 3.x** — `MovementDataset` items carried the
|
|
170
|
+
raster's `Affine` transform as their fifth element, and since `affine` 3.0
|
|
171
|
+
`Affine` is no longer a tuple, so PyTorch's `default_collate` raised a
|
|
172
|
+
`TypeError` on the first batch. The fifth element is now a `[6]` float64
|
|
173
|
+
tensor of the transform's coefficients `(a, b, c, d, e, f)`, which batches on
|
|
174
|
+
any `affine` version; rebuild the transform with `Affine(*coeffs.tolist())`,
|
|
175
|
+
or use `dataset.raster_transform`. The training and validation loops ignore
|
|
176
|
+
this element, so they are unaffected.
|
|
177
|
+
- **Sub-pixel jitter was hard-coded to a 25 m cell.** `simulate_next_step`
|
|
178
|
+
scattered each sampled location by a truncated normal on [0, 25] m regardless
|
|
179
|
+
of the raster's actual resolution. On a 20 m raster that put up to 1.25 cells
|
|
180
|
+
of noise on every simulated step and pushed locations outside the cell the
|
|
181
|
+
model had sampled; on a 100 m raster it never left the first quarter of the
|
|
182
|
+
cell. The cell size now comes from the transform, which is the authority on
|
|
183
|
+
the grid the model was trained against. **Simulated trajectories will differ
|
|
184
|
+
from previous versions on any raster that is not 25 m.**
|
|
185
|
+
- **A habitat-only training stage saved the wrong epoch.** The combined loss is
|
|
186
|
+
`habitat + movement + logZ`, and `logZ = logsumexp(habitat + movement)` — the
|
|
187
|
+
overlap between the habitat surface and the movement kernel — moves with the
|
|
188
|
+
habitat weights. The combined loss and the habitat loss are the same
|
|
189
|
+
used-versus-available contrast measured against different availability sets:
|
|
190
|
+
the combined loss scores habitat against the cells within reach (~109 effective
|
|
191
|
+
cells of a 75x75 window on a feral-pig model), the habitat loss against the
|
|
192
|
+
whole window (~5460). So with movement frozen the combined loss can keep
|
|
193
|
+
falling — habitat discriminating better among near neighbours — while the
|
|
194
|
+
density it gives the observed location gets *worse*. Since checkpointing keyed
|
|
195
|
+
on the combined loss unconditionally, every epoch of such a stage looked like an
|
|
196
|
+
improvement and overwrote the checkpoint, ending on a habitat surface well past
|
|
197
|
+
its best — in one feral-pig run, habitat bottomed out at epoch 12 of a 10-epoch
|
|
198
|
+
stage and then gave back three quarters of what it had learned while the
|
|
199
|
+
combined loss kept falling.
|
|
200
|
+
`EarlyStopping(checkpoint_on='active')` now keys the checkpoint on whichever
|
|
201
|
+
head is training during a single-head stage, falling back to the combined loss
|
|
202
|
+
when both are; `head_paths=` additionally keeps each head's own best epoch in
|
|
203
|
+
its own file. The default remains `'total'` — for the finished *joint* model,
|
|
204
|
+
and for a habitat surface intended as a *selection* map, that is still the
|
|
205
|
+
right criterion; `'active'` is mainly a diagnostic for whether the habitat head
|
|
206
|
+
is learning at all. `negativeLogLikeLoss`'s docstring now spells out what `logZ`
|
|
207
|
+
is, gives the gradients of both criteria, and notes that neither is a spatial
|
|
208
|
+
operation — the habitat CNN has no positional input and is
|
|
209
|
+
translation-equivariant, so the difference between them is entirely in which
|
|
210
|
+
covariate values enter the available side of the contrast.
|
|
211
|
+
- **Environmental layers were not scaled to [0, 1].** `load_environmental_layers`
|
|
212
|
+
divided by the maximum rather than the range — `(data - lo) / hi` instead of
|
|
213
|
+
`(data - lo) / (hi - lo)` — which only lands in [0, 1] when the minimum happens
|
|
214
|
+
to be zero. Sentinel-2 indices stored as ×10 000 (`lo=-10000, hi=10000`) came
|
|
215
|
+
out in [0, 2]; an all-negative layer came out negative *and* axis-flipped,
|
|
216
|
+
reversing the sign of everything the habitat CNN learned from it; a layer with
|
|
217
|
+
`hi == 0` divided by zero. A constant layer now maps to a flat 0 instead of
|
|
218
|
+
raising. **Checkpoints trained on the old scaling will not transfer** — the
|
|
219
|
+
inputs have changed, so retrain.
|
|
220
|
+
- **`conv_movement` was registered with no optimiser.** `make_optimisers` covered
|
|
221
|
+
only `fcn_movement_all` and `conv_habitat`, so the movement CNN trunk received
|
|
222
|
+
gradients on every `backward()` but was never zeroed and never stepped. Its
|
|
223
|
+
`.grad` accumulated monotonically across the whole run: the trunk stayed at its
|
|
224
|
+
random initialisation, and once the buffer overflowed `_grads_are_finite` —
|
|
225
|
+
which iterates over every parameter — began returning `False`, silently
|
|
226
|
+
skipping *every* subsequent update, habitat's included. `conv_movement` now
|
|
227
|
+
sits in the movement optimiser alongside `fcn_movement_all`.
|
|
228
|
+
- `train_loop` zeroes gradients with `model.zero_grad(set_to_none=True)` rather
|
|
229
|
+
than per-optimiser, so no parameter can be left out of the reset however the
|
|
230
|
+
optimisers are configured. The manual stash-and-restore of habitat gradients
|
|
231
|
+
around the two optimiser steps has been removed: the optimisers own disjoint
|
|
232
|
+
parameter sets and `Adam.step()` only reads `.grad` for its own parameters, so
|
|
233
|
+
it was a no-op.
|
|
234
|
+
- **Both learning-rate schedulers stepped on the combined validation loss.** That
|
|
235
|
+
loss is `habitat + movement + logZ` and is dominated by the movement component
|
|
236
|
+
(~1.2 nats of movement against ~0.05 of habitat over a typical run), so the
|
|
237
|
+
habitat learning rate was being cut whenever the *movement* head plateaued.
|
|
238
|
+
Each scheduler now steps on its own component, and only while that component is
|
|
239
|
+
training — stepping a frozen head's scheduler would penalise it for an
|
|
240
|
+
improvement it was never given the chance to make.
|
|
241
|
+
- **NaN movement probability surfaces during training.** In float32 the von
|
|
242
|
+
Mises normaliser `I₀(κ)` overflows to `inf` at κ ≈ 89 — reachable from a raw
|
|
243
|
+
FCN output of only `log(89) ≈ 4.5`. With both mixture components overflowing,
|
|
244
|
+
the log-sum-exp evaluated `-inf - (-inf)` and the entire surface became NaN;
|
|
245
|
+
with only one overflowing, the forward pass stayed finite but produced NaN
|
|
246
|
+
gradients that silently propagated into every weight on the next optimiser
|
|
247
|
+
step. `Params_to_Grid_Block._vonmises_log` now uses the exponentially-scaled
|
|
248
|
+
Bessel function, `log I₀(κ) = κ + log(i0e(κ))`, giving
|
|
249
|
+
`κ·(cos(θ-μ) - 1) - log(2π) - log(i0e(κ))`, whose exponent is ≤ 0 for every κ.
|
|
250
|
+
- Mixture components are combined with `torch.logsumexp` instead of a manual
|
|
251
|
+
max/exp/log, which returned NaN when both components were `-inf`.
|
|
252
|
+
- Raw FCN outputs are clamped (`Params_to_Grid_Block.raw_clamp`, default 10.0)
|
|
253
|
+
before exponentiation. Previously `exp` overflowed to `inf` at a raw value of
|
|
254
|
+
≈ 88, making `lgamma(inf) - inf` NaN, and scale underflowed to 0 at ≈ -88.
|
|
255
|
+
- `torch.special.i0e` is called on the active device. The previous `.cpu()`
|
|
256
|
+
round-trip for MPS forced a host sync on every forward pass; `i0`/`i0e` now
|
|
257
|
+
have CPU, CUDA and MPS kernels registered (verified on torch 2.12).
|
|
258
|
+
- `distance_layer` and `bearing_layer` are registered buffers, so `model.to()`
|
|
259
|
+
moves them once instead of copying them host→device on every forward pass.
|
|
260
|
+
They use `persistent=False`: both are constants derived from `image_dim` and
|
|
261
|
+
`pixel_size`, so they stay out of `state_dict` and checkpoint keys are
|
|
262
|
+
unchanged.
|
|
263
|
+
- `train_loop` skips batches whose loss or gradients are not finite rather than
|
|
264
|
+
stepping the optimiser on them, and excludes them from the epoch mean. A
|
|
265
|
+
single bad batch no longer makes a run unrecoverable.
|
|
266
|
+
- The epoch mean is now averaged over contributing batches, not `num_batches`.
|
|
267
|
+
- Corrected the `Params_to_Grid_Block_ChV` docstring: it subtracts `log(r)` from
|
|
268
|
+
the log-density (dividing the density by `r`), not "by `log(r)`".
|
|
269
|
+
|
|
270
|
+
### Changed
|
|
271
|
+
- **Breaking:** `skip_epoch0_training` has been removed from `train_loop`. It had
|
|
272
|
+
no epoch awareness — it was a per-call switch that `fit` never passed, so its
|
|
273
|
+
default applied to *every* epoch, and with that default at `True` an entire run
|
|
274
|
+
executed forward-only under `torch.set_grad_enabled(False)`. It also measured a
|
|
275
|
+
training-set baseline when the useful number is a validation one. `fit` now runs
|
|
276
|
+
one validation pass before the first epoch and records it at index 0 of every
|
|
277
|
+
history list, with `train_losses[0] = nan`.
|
|
278
|
+
- `history` gains a `stage` key recording which stage each epoch belonged to
|
|
279
|
+
(`-1` for the pre-training baseline row).
|
|
280
|
+
- **Breaking (checkpoints):** mixture weights are now `log_softmax` over the raw
|
|
281
|
+
FCN outputs. Previously `softmax(exp(raw))` was applied — a double exponential
|
|
282
|
+
that saturated to exactly `(1, 0)` by a raw value of ≈ 4.75, zeroing the weight
|
|
283
|
+
gradient and putting `log(0) = -inf` into the surface. Movement parameters
|
|
284
|
+
2, 5, 8 and 11 therefore have a different meaning, so checkpoints written by
|
|
285
|
+
≤ 0.2.3 will be reinterpreted rather than failing to load. The step-length and
|
|
286
|
+
turning-angle densities themselves are unchanged (agreement to ~6e-6 when the
|
|
287
|
+
two weights are equal).
|
|
288
|
+
- `negativeLogLikeLoss` raises a `RuntimeWarning` once per non-finite surface
|
|
289
|
+
instead of printing on every batch, and performs one device sync per batch
|
|
290
|
+
rather than three.
|
|
291
|
+
- `_expand` uses `expand` rather than `repeat`, returning a view instead of
|
|
292
|
+
materialising a `[B, H, W]` copy per parameter.
|
|
293
|
+
|
|
294
|
+
## [0.2.3] - 2026-05-31
|
|
295
|
+
|
|
296
|
+
### Added
|
|
297
|
+
- **`EarlyStopping(monitor='both')`.** Keeps a separate patience budget for the
|
|
298
|
+
habitat and movement components and stops only once every active component has
|
|
299
|
+
plateaued, so a converged movement head can no longer end a run in which habitat
|
|
300
|
+
is still improving. Checkpointing still keys on the combined loss — the joint
|
|
301
|
+
likelihood remains the right model-selection criterion; only the stopping rule
|
|
302
|
+
changes. `reset()` clears the counters at stage boundaries while leaving the
|
|
303
|
+
best score and saved checkpoint intact.
|
|
304
|
+
- **`fit(stages=...)`** runs the sub-networks as a coordinate ascent on the same
|
|
305
|
+
joint likelihood, e.g. movement alone to establish an availability kernel, then
|
|
306
|
+
habitat alone to its own convergence, then both. A frozen branch keeps
|
|
307
|
+
contributing its surface to the loss as a fixed offset; only its weights stop
|
|
308
|
+
moving, so the objective is unchanged rather than reweighted. This is not what
|
|
309
|
+
`negativeLogLikeLoss(freeze_movement=True)` does — that flag *drops* the movement
|
|
310
|
+
surface from the loss, which would make the habitat head learn a distance-decay
|
|
311
|
+
bump in order to explain the movement structure itself. Early stopping ends the
|
|
312
|
+
current stage and advances to the next. `reset_optimiser_state` (default `True`)
|
|
313
|
+
clears the Adam moments of a branch when it becomes active again.
|
|
314
|
+
- `set_trainable(model, *, habitat, movement)` toggles `requires_grad` per
|
|
315
|
+
sub-network. Exported from the package root.
|
|
316
|
+
- See `Habitat_movement_loss_balancing.md` for why the fix for the
|
|
317
|
+
habitat/movement asymmetry is a monitoring change rather than a loss weight.
|
|
318
|
+
- **Versioned checkpoints.** `EarlyStopping` now writes a dict with
|
|
319
|
+
`deepssf_checkpoint_format`, `deepssf_version`, `val_loss` and `state_dict`
|
|
320
|
+
keys instead of a bare `state_dict` (`CHECKPOINT_FORMAT = 2`). Because the
|
|
321
|
+
`state_dict` keys themselves did not change, a pre-0.3.0 file would otherwise
|
|
322
|
+
load without error while silently misreading movement parameters 2, 5, 8
|
|
323
|
+
and 11 — the marker makes that case fail loudly.
|
|
324
|
+
- `load_checkpoint(path, model=None, *, map_location=None, allow_legacy=False,
|
|
325
|
+
strict=True)` reads a checkpoint, verifies its format, and optionally loads it
|
|
326
|
+
into a model. Legacy (format 1) files raise `RuntimeError` unless
|
|
327
|
+
`allow_legacy=True`; files from a future format are refused with an
|
|
328
|
+
upgrade message. Exported from the package root alongside `CHECKPOINT_FORMAT`.
|
|
329
|
+
- `grad_clip` argument on `fit` and `train_loop` for global gradient-norm
|
|
330
|
+
clipping (`None` by default, so existing behaviour is unchanged).
|
|
331
|
+
- Regression tests covering forward and backward finiteness at extreme movement
|
|
332
|
+
parameters, log-normalisation of the surface, and non-vanishing mixture-weight
|
|
333
|
+
gradients.
|
|
334
|
+
- 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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: deepSSF
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|