Skip to content

Commit 3ee4e1e

Browse files
Add functional transcendentals + scalar-affine ops (exp/log/scale/shift/affine)
Surface differentiable elementwise exp/log and scalar-affine maps on the nn.functional API, so scientific forward models (radiative transfer, dielectric mixing, kinetics — built from exp/log of an affine argument rather than NN activations) can be written as a pure autograd.fn1.Fn and differentiated by the autograd engine, with no hand-coded gradient. - F.{exp,log,scale,shift,affine} in Functional/Core.lean: thin lifts of the existing Ops.{exp,log,scale,const} primitives, which already carry registered backwards, so reverse-mode jacrev/grad works through them unchanged. - Surfaced on nn.functional via both export bridges (FunctionalBatch + Seeded). - Self-checking positive/negative example (NN/Examples/Functional/Transcendentals, `lake exe transcendentals_check`): autograd gradients of exp, 3x+1, and exp(-2x) match the closed form; a wrong-sign control is rejected. - Blueprint chapter Ch2_Frontend/ScientificForwardModels. Note: the example exe links the toolchain libc++ at runtime (native autograd backend); set LD_LIBRARY_PATH to <toolchain>/lib accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 146353b commit 3ee4e1e

7 files changed

Lines changed: 256 additions & 0 deletions

File tree

NN/API/Public/NN/FunctionalBatch.lean

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export TorchLean.F
3636
embedding embeddingRowsNat embeddingBatchSeqNat mean
3737
dropoutSeeded)
3838

39+
-- Elementwise transcendentals + scalar-affine for scientific forward models
40+
-- (fully qualified to disambiguate the `exp`/`log`/`scale` identifiers, which
41+
-- also name primitives in scope).
42+
export _root_.Runtime.Autograd.TorchLean.F
43+
(exp log scale shift affine)
44+
3945
end functional
4046

4147
/-!

NN/API/Public/Seeded.lean

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def globalAvgPoolNCHW := pure.globalAvgPoolNCHW
130130
namespace functional
131131
export pure.functional
132132
(square checkpoint
133+
exp log scale shift affine
133134
detach stopGrad
134135
addB mulB
135136
embedding mean
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/-
2+
Copyright (c) 2026 TorchLean
3+
Released under MIT license as described in the file LICENSE.
4+
Authors: TorchLean Team
5+
-/
6+
7+
module
8+
9+
public import NN
10+
11+
/-!
12+
# Functional transcendentals + scalar-affine: autograd correctness
13+
14+
Positive / negative example for the `nn.functional.{exp, log, scale, shift, affine}`
15+
ops added for scientific forward models — e.g. the soil-moisture retrieval that
16+
combines SMAP (Soil Moisture Active Passive) and NISAR (NASA–ISRO Synthetic
17+
Aperture Radar) observations through the AVS (Attenuation–Volume–Surface) model,
18+
whose surface term is `exp(-2·b·NDVI)·c·|R|²`.
19+
20+
The point is that these ops are differentiated by the **autograd engine**, so a
21+
forward model written once yields its gradient with no hand-coded derivative.
22+
Each check differentiates a tiny function and compares the autograd gradient to
23+
the closed form:
24+
25+
* positive controls — the gradient matches the analytic value;
26+
* negative controls — a deliberately *wrong* analytic value (notably the
27+
wrong-sign gradient of `exp(-2x)`) does **not** match. That is exactly the
28+
defect class — a sign/factor error in a hand-coded Jacobian — that deriving the
29+
gradient by autograd eliminates.
30+
31+
`#eval checkAll` runs at build time and fails the build on any regression.
32+
-/
33+
34+
@[expose] public section
35+
36+
namespace NN.Examples.Functional.Transcendentals
37+
38+
open Spec
39+
open Tensor
40+
open NN.Tensor
41+
open NN.API
42+
43+
/-! ## Functions under test (written once; gradients come from autograd) -/
44+
45+
/-- `f(x) = eˣ`. -/
46+
def expFn : autograd.fn1.Fn Spec.Shape.scalar Spec.Shape.scalar :=
47+
fun x => nn.functional.exp x
48+
49+
/-- `f(x) = e^{-2x}` — the shape of the AVS canopy two-way transmittance as a
50+
function of the attenuation parameter. -/
51+
def expNeg2Fn : autograd.fn1.Fn Spec.Shape.scalar Spec.Shape.scalar :=
52+
fun x => do
53+
let u ← nn.functional.scale x (-Numbers.two)
54+
nn.functional.exp u
55+
56+
/-- `f(x) = 3·x + 1` via the scalar-affine op. -/
57+
def affineFn : autograd.fn1.Fn Spec.Shape.scalar Spec.Shape.scalar :=
58+
fun x => nn.functional.affine x Numbers.three Numbers.one
59+
60+
/-! ## Float checks -/
61+
62+
/-- Absolute-tolerance float compare. -/
63+
def approx (a b : Float) (tol : Float := 1e-6) : Bool := (a - b).abs ≤ tol
64+
65+
/-- Positive control: `name`'s autograd gradient ≈ expected; throws on mismatch. -/
66+
def expectGrad (name : String) (got expected : Float) (tol : Float := 1e-6) : IO Unit :=
67+
if approx got expected tol then
68+
IO.println s!"[PASS] {name}: grad = {got}{expected}"
69+
else
70+
throw <| IO.userError s!"[FAIL] {name}: grad = {got}, expected {expected}"
71+
72+
/-- Negative control: the gradient must *not* equal `wrong`; throws if it does. -/
73+
def expectNot (name : String) (got wrong : Float) (tol : Float := 1e-6) : IO Unit :=
74+
if approx got wrong tol then
75+
throw <| IO.userError s!"[FAIL-NEG] {name}: grad = {got} wrongly matched {wrong}"
76+
else
77+
IO.println s!"[PASS-NEG] {name}: grad = {got}{wrong} (test discriminates)"
78+
79+
/-- Differentiate a scalar→scalar `Fn` at a Float point, returning the gradient. -/
80+
def gradAt (f : autograd.fn1.Fn Spec.Shape.scalar Spec.Shape.scalar) (x0 : Float) :
81+
IO Float := do
82+
let x : Spec.Tensor Float Spec.Shape.scalar := Spec.fill (x0 : Float) Spec.Shape.scalar
83+
let g ← autograd.fn1.grad (α := Float) f x
84+
pure (Spec.toScalarSpec g)
85+
86+
def checkAll : IO Unit := do
87+
-- exp: d/dx eˣ = eˣ
88+
let ge ← gradAt expFn 0.5
89+
expectGrad "exp" ge (Float.exp 0.5)
90+
expectNot "exp≠1" ge 1.0 -- a constant-1 gradient would be caught
91+
92+
-- affine: d/dx (3x+1) = 3
93+
let ga ← gradAt affineFn 0.5
94+
expectGrad "affine(3x+1)" ga 3.0
95+
expectNot "affine≠1" ga 1.0 -- the slope is 3, not 1
96+
97+
-- exp(-2x): d/dx e^{-2x} = -2·e^{-2x}
98+
let gn ← gradAt expNeg2Fn 0.5
99+
expectGrad "exp(-2x)" gn ((-2.0) * Float.exp (-1.0))
100+
-- THE AVS bug class: the wrong-SIGN analytic (+2·e^{-2x}) must NOT match.
101+
expectNot "exp(-2x) sign" gn (( 2.0) * Float.exp (-1.0))
102+
103+
IO.println "[transcendentals] all positive + negative controls passed ✓"
104+
105+
end NN.Examples.Functional.Transcendentals
106+
107+
/-- Compiled entry point. Autograd uses the native runtime, so this runs as a
108+
compiled `lean_exe` (`lake exe transcendentals_check`), not via `#eval` (the
109+
interpreter cannot load the native tape externs). -/
110+
def main : IO Unit := NN.Examples.Functional.Transcendentals.checkAll

NN/Runtime/Autograd/TorchLean/Functional/Core.lean

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,60 @@ def square {α : Type} [Context α] [DecidableEq Shape]
4949
{s : Shape} (x : RefTy (m := m) (α := α) s) : m (RefTy (m := m) (α := α) s) :=
5050
mul (m := m) (α := α) (s := s) x x
5151

52+
/-! ## Elementwise transcendentals (scientific forward-model ops)
53+
54+
These lift the primitive `Ops.{exp,log}` and the scalar-affine
55+
`Ops.scale`/`Ops.const` into the functional surface, so geophysical / scientific
56+
forward models — which lean on `exp`/`log` of an affine argument rather than the
57+
NN-flavoured `relu`/`square`/`softmax` ops — can be written directly as a pure
58+
`Function1.Fn` and differentiated by the autograd engine. Each wraps a primitive
59+
that already carries a registered backward, so reverse-mode `jacrev`/`grad`
60+
works through them unchanged.
61+
62+
PyTorch analogues: `torch.exp`, `torch.log`, and `c·x` / `c·x + k` via
63+
`torch.mul`/`torch.add` against scalars. -/
64+
65+
/-- Elementwise exponential `x ↦ eˣ`. PyTorch: `torch.exp`. -/
66+
def exp {α : Type} [Context α] [DecidableEq Shape]
67+
{m : TypeType} [Monad m] [Ops (m := m) (α := α)]
68+
{s : Shape} (x : RefTy (m := m) (α := α) s) : m (RefTy (m := m) (α := α) s) :=
69+
_root_.Runtime.Autograd.Torch.exp (m := m) (α := α) (s := s) x
70+
71+
/-- Elementwise natural log `x ↦ ln x`. PyTorch: `torch.log`. -/
72+
def log {α : Type} [Context α] [DecidableEq Shape]
73+
{m : TypeType} [Monad m] [Ops (m := m) (α := α)]
74+
{s : Shape} (x : RefTy (m := m) (α := α) s) : m (RefTy (m := m) (α := α) s) :=
75+
_root_.Runtime.Autograd.Torch.log (m := m) (α := α) (s := s) x
76+
77+
/-- Multiply by a compile-time-or-runtime constant scalar `c`: `x ↦ c · x`.
78+
A re-export of the primitive `Ops.scale` onto the functional surface (it powers
79+
`mean`, but was not itself exposed as `nn.functional.*`). -/
80+
def scale {α : Type} [Context α] [DecidableEq Shape]
81+
{m : TypeType} [Monad m] [Ops (m := m) (α := α)]
82+
{s : Shape} (x : RefTy (m := m) (α := α) s) (c : α) : m (RefTy (m := m) (α := α) s) :=
83+
_root_.Runtime.Autograd.Torch.scale (m := m) (α := α) (s := s) x c
84+
85+
/-- Add a constant scalar `c` to every element: `x ↦ x + c`. Builds the
86+
constant via `Ops.const` at scalar shape and broadcasts it to `s` (same pattern
87+
as the dropout keep-probability broadcast). -/
88+
def shift {α : Type} [Context α] [DecidableEq Shape]
89+
{m : TypeType} [Monad m] [Ops (m := m) (α := α)]
90+
{s : Shape} (x : RefTy (m := m) (α := α) s) (c : α) : m (RefTy (m := m) (α := α) s) := do
91+
let cs ← _root_.Runtime.Autograd.Torch.const (m := m) (α := α) (s := Shape.scalar)
92+
(Tensor.scalar c)
93+
let cb ← _root_.Runtime.Autograd.Torch.broadcastTo (m := m) (α := α)
94+
(s₁ := Shape.scalar) (s₂ := s) (Shape.CanBroadcastTo.scalar_to_any s) cs
95+
_root_.Runtime.Autograd.Torch.add (m := m) (α := α) (s := s) x cb
96+
97+
/-- Scalar affine map `x ↦ c · x + k`. The single most common building block of
98+
linearised physical forward models (e.g. the SMAP-NISAR AVS surface/vegetation
99+
terms). Composes `scale` then `shift`. -/
100+
def affine {α : Type} [Context α] [DecidableEq Shape]
101+
{m : TypeType} [Monad m] [Ops (m := m) (α := α)]
102+
{s : Shape} (x : RefTy (m := m) (α := α) s) (c k : α) : m (RefTy (m := m) (α := α) s) := do
103+
let sx ← scale (m := m) (α := α) (s := s) x c
104+
shift (m := m) (α := α) (s := s) sx k
105+
52106
/-! ## Checkpointing (semantics-first identity wrapper) -/
53107

54108
/--

blueprint/TorchLeanBlueprint/Guide.lean

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import TorchLeanBlueprint.Guide.Ch2_Frontend.TrainingFromScratch
1414
import TorchLeanBlueprint.Guide.Ch2_Frontend.TorchLeanAPI
1515
import TorchLeanBlueprint.Guide.Ch2_Frontend.ExecutionModes
1616
import TorchLeanBlueprint.Guide.Ch2_Frontend.AutogradWalkthrough
17+
import TorchLeanBlueprint.Guide.Ch2_Frontend.ScientificForwardModels
1718
import TorchLeanBlueprint.Guide.Ch2_Frontend.RuntimeAndAutograd
1819
import TorchLeanBlueprint.Guide.Ch2_Frontend.PyTorchRoundtrip
1920
import TorchLeanBlueprint.Guide.Ch3_Backend.GraphsAndIR
@@ -135,6 +136,8 @@ verification.
135136

136137
{include 2 TorchLeanBlueprint.Guide.Ch2_Frontend.AutogradWalkthrough}
137138

139+
{include 2 TorchLeanBlueprint.Guide.Ch2_Frontend.ScientificForwardModels}
140+
138141
{include 2 TorchLeanBlueprint.Guide.Ch2_Frontend.RuntimeAndAutograd}
139142

140143
{include 2 TorchLeanBlueprint.Guide.Ch2_Frontend.PyTorchRoundtrip}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import VersoManual
2+
3+
open Verso.Genre Manual
4+
5+
#doc (Manual) "Scientific Forward Models: Transcendentals and Affine Maps" =>
6+
%%%
7+
tag := "scientific-forward-models"
8+
%%%
9+
10+
TorchLean's functional surface began life NN-flavoured: `square`, `mean`, `relu`,
11+
`sigmoid`, `softmax`. Those cover deep-learning layers, but a large class of
12+
*scientific* forward modelsradiative transfer, dielectric mixing, kinetics
13+
are not built from activations. They are built from an `exp` or `log` of an
14+
*affine* argument. This chapter documents the small set of functional ops added
15+
for that use case, and the reason they matter: a forward model written once is
16+
differentiated by the autograd engine, with no separately maintained gradient.
17+
18+
# The ops
19+
20+
All five are thin lifts of primitives that already carry a registered backward,
21+
so reverse-mode `grad` / `jacrev` works through them unchanged. They live in the
22+
functional namespace alongside `square` and `mean`:
23+
24+
- `nn.functional.exp` — elementwise `eˣ` (`torch.exp`).
25+
- `nn.functional.log` — elementwise `ln x` (`torch.log`).
26+
- `nn.functional.scale x c` — multiply by a constant scalar, `c · x`.
27+
- `nn.functional.shift x c` — add a constant scalar, `x + c`.
28+
- `nn.functional.affine x c k` — the affine map `c · x + k`, the single most
29+
common building block of a linearised physical forward model.
30+
31+
Because they are ordinary functional ops, they compose inside a pure
32+
`autograd.fn1.Fn` exactly like the NN ops do.
33+
34+
# Why this matters: the gradient is derived, not written
35+
36+
The motivating case is the SMAP-NISAR soil-moisture retrieval. Its forward model
37+
relates radar backscatter to soil moisture through
38+
39+
```
40+
σ⁰ = a · NDVI + exp(-2 · b · NDVI) · c · |R|² + d
41+
```
42+
43+
Operationally this is fit per pixel by least squares with a *hand-coded* analytic
44+
Jacobian — and a second, byte-duplicated copy for the JIT path. A sign or factor
45+
error in that Jacobian does not crash anything; it silently degrades the fit, and
46+
the two copies can drift apart. No validation statistic catches it.
47+
48+
With the ops above, the surface term is a one-line `Fn`, and its derivative comes
49+
from autograd. The hand-coded Jacobian becomes *redundant*: instead of trusting a
50+
transcription, the gradient is generated from the forward model and can be checked
51+
against it.
52+
53+
# Worked check (positive and negative controls)
54+
55+
The example `NN.Examples.Functional.Transcendentals` differentiates three tiny
56+
functions and compares the autograd gradient to the closed form. Run it with
57+
`lake exe transcendentals_check`.
58+
59+
```
60+
def expNeg2Fn : autograd.fn1.Fn Spec.Shape.scalar Spec.Shape.scalar :=
61+
fun x => do
62+
let u ← nn.functional.scale x (-Numbers.two) -- -2 · x
63+
nn.functional.exp u -- e^{-2x}
64+
```
65+
66+
The check asserts, at `x = 0.5`:
67+
68+
- *positive* — the autograd gradient equals the analytic `-2 · e^{-2x} = -0.735759`;
69+
- *negative* — it does *not* equal the wrong-*sign* analytic `+2 · e^{-2x} = +0.735759`.
70+
71+
That negative control is the whole point in miniature: it is exactly the
72+
defect — a sign error in a hand-written derivative — that deriving the gradient
73+
by autograd eliminates. The positive controls for `exp` (`grad = eˣ`) and the
74+
affine map (`grad (3x+1) = 3`) round out the suite, and the executable exits
75+
non-zero on any regression.

lakefile.lean

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,13 @@ lean_exe torchlean_lint where
184184
lean_exe torchlean where
185185
root := `NN.Examples.Models.Runner
186186

187+
-- Self-checking positive/negative example for the functional transcendental +
188+
-- scalar-affine ops (`nn.functional.{exp,log,scale,shift,affine}`). Runs the
189+
-- autograd checks compiled; exits non-zero on any regression.
190+
-- `lake exe transcendentals_check`
191+
lean_exe transcendentals_check where
192+
root := `NN.Examples.Functional.Transcendentals
193+
187194
-- API documentation (HTML) via `lake build NN:docs`.
188195
require «doc-gen4» from git
189196
"https://github.com/leanprover/doc-gen4" @ "v4.30.0"

0 commit comments

Comments
 (0)