Everything below refers to one concrete object: a 10-point, 2-dimensional random walk (cumulative sum of Gaussian steps, seed 0), read as a piecewise-linear path .
| k | x₁ | x₂ |
|---|---|---|
| 0 | 1.7641 | 0.4002 |
| 1 | 2.7428 | 2.6411 |
| 2 | 4.6103 | 1.6638 |
| 3 | 5.5604 | 1.5124 |
| 4 | 5.4572 | 1.9230 |
| 5 | 5.6013 | 3.3773 |
| 6 | 6.3623 | 3.4990 |
| 7 | 6.8062 | 3.8326 |
| 8 | 8.3002 | 3.6275 |
| 9 | 8.6133 | 2.7734 |
For a word over the alphabet (one letter per path dimension), the corresponding signature entry is an iterated integral:
Depth-k truncation keeps every word up to length k. For a 2-dimensional path that's numbers at depth 3 — exactly what iisignature.sig(path, 3) returned. The empty word gets coefficient 1 always — it's the identity element, which is why RoughPy prints its signature starting with 1().
| coordinate 1 | coordinate 2 | |
|---|---|---|
| Xᴛ − X₀ | 6.849257 | 2.373225 |
S¹ (from iisignature.sig) | 6.849257 | 2.373225 |
Split into symmetric and antisymmetric parts. The symmetric (diagonal) part turns out to be exactly determined by level 1 — a pure calculus identity, true for any 1-d path regardless of how much it oscillates:
| value | |
|---|---|
| ½(S¹)² | 23.456160 |
| S¹¹ (diagonal, from signature) | 23.456160 |
The antisymmetric part is the Lévy area — signed area swept between the path and the chord shown in §1.1 — and it's genuinely new information: it depends on the order in which the two coordinates moved, which the level-1 and diagonal level-2 terms cannot see.
The Gyurkó–Lyons construction pairs a coordinate with a one-tick-delayed copy of itself, doubling the resolution: (lead) steps to the next value first; (lag) holds, then catches up. On this 2D (lead, lag) path, the antisymmetric cross-term is an exact identity, not an approximation:
| value | |
|---|---|
| realized ∑(Δx₁)² | 8.48624822212359 |
| S(lead,lag) − S(lag,lead) | 8.486248222123592 |
This is the mechanism behind the claim that lead-lag preprocessing recovers quadratic variation — and it's exact, not asymptotic, which is a much stronger thing to be able to say in an interview than "signatures relate to volatility."
The signature lives in the tensor algebra — the free associative algebra on . Concatenating path segments is the ring product of their signatures:
iisignature.sigcombine
Split the path at index 5 into two halves, compute each signature separately, combine them via the tensor product — the result matches the whole-path signature to 8.8×10⁻⁷ (floating-point noise). This is the identity that makes signatures grouplike: they form a group under this product (identity = the level-0 term, inverse = the signature of the time-reversed path). It's exactly what a streaming/rolling-window update needs — drop the oldest segment by left-multiplying its group inverse, append the new one by right-multiplying — O(1) per tick in the steady state, instead of recomputing the whole window (built in Part III).
The ordinary scalar product of two signature entries equals a sum over interleavings (shuffles) of the two words — the commutative Hopf algebra dual to the concatenation product above:
| identity | LHS | RHS |
|---|---|---|
| S¹·S² = S¹²+S²¹ | 16.254831 | 16.254831 |
| (S¹)² = 2·S¹¹ | 46.912319 | 46.912319 |
This is the correctness oracle for any signature implementation, including the pure-numpy reference this package carries as a test oracle — a much stronger test than "looks about right," and it's a check that has to hold on every random path, not just this one. It's exactly what tests/test_backend.py in Chapter 2 turns into an automated regression test.
Because a signature is grouplike, its logarithm (via the tensor algebra's own exp/log) is primitive, and primitives of this Hopf algebra are exactly the free Lie algebra on :
This is PBW-theorem territory: the Lyndon-word basis of the free Lie algebra gives a far smaller, information-lossless basis than the full tensor words. Computed in Chapter 4, once the transformer and preprocessing pipeline below existed to build it on top of.
| iisignature 0.24 | RoughPy 0.3.0 | |
|---|---|---|
| cp314 wheel on PyPI | none — source build | yes, prebuilt |
| build needed manual fix | yes (numpy + --no-build-isolation) | no |
| maintenance | low (last dep. update 0.24) | active, Lyons-lab |
| API | simple, flat arrays | richer, more ceremony |
| agreement with other backend | max abs diff 4.6×10⁻⁵ at the time (see §2.3 — this was a bug in the wrapper, not the backend) | |
Both computed the same signature. RoughPy became the default given wheel availability and maintenance status; iisignature stays available behind the same narrow interface as a cross-check / reference during testing.
src/rollsig/_backend.py is the only file that imports RoughPy or iisignature directly. Everything else calls one function:
signature(path: np.ndarray, depth: int, backend: Backend = "roughpy") -> np.ndarray
which returns a flat array of levels 1..depth in lexicographic word order — the same layout iisignature.sig already uses, so both backends are drop-in interchangeable and every identity from Chapter 1 works unchanged as a test on either one. The point of isolating this in one narrow file: the moment a better backend appears (or RoughPy's API shifts again), only _backend.py changes — not the transformer, not the tests.
src/rollsig/transformer.py is a normal sklearn fit/transform estimator. The entire causality guarantee comes down to one line in transform:
for t in range(n_samples):
start = max(0, t + 1 - self.window)
out[t] = signature(X[start : t + 1], self.depth, backend=self.backend)
Row t is built only from X[start : t+1] — data up to and including t, never beyond it. This loops per-timestep and recomputes each window's signature from scratch (O(window) per tick) rather than vectorizing or reusing the previous window's result — deliberate for v0.1: the constant-per-tick version is exactly Chen's identity from §1.6 (drop the departing segment via its group inverse, append the new one), and that's scoped as its own later chapter, not smuggled in early.
test_causal_alignment in tests/test_transformer.py fits the transformer on a truncated prefix series[:t+1] and asserts the last row matches the row at index t from fitting on the full series. If any future change introduced lookahead, this test would catch it.Both are silent — no exception, no warning, just a wrong number — which is the dangerous kind.
RoughPy's Scalar doesn't support float() directly. The obvious workaround, going through its pretty-printed string, quietly rounds to display precision:
float(str(sig[tensor_key])) # 6.849256... becomes 6.84926
sig[tensor_key].to_float() # full float64 precision
This alone explained the entire ~4.6×10⁻⁵ "backend disagreement" reported in §1.9 — it wasn't RoughPy vs iisignature disagreeing, it was str() lying.
Building a TensorKey from a shared context object gives the wrong value for words like (1,1) — it silently returns a different entry's value, with a correctly-labelled repr that makes the bug easy to miss on a quick print:
rp.TensorKey([1, 1], ctx=ctx) # prints "(1,1)" but reads back S² 's value
rp.TensorKey([1, 1], width=2, depth=3) # correct, and doesn't itself warn
Caught only because §1.4's diagonal identity (S¹¹ = ½(S¹)²) failed in the test suite — another point for treating the algebraic identities as tests rather than assertions.
| status | |
|---|---|
| tests | initial core coverage; extended in Chapter 3 |
| backend agreement | 1×10⁻⁸ after the fixes — true float64 precision, not a coincidence |
| PR | v0.1-signature-transformer-skeleton → PR #25, open |
| still open at this point | hypothesis property tests (Chapter 4 adds the first one), log-signatures (Chapter 4), CI, and the streaming engine (Part III) |
A raw price path is not always the path we want the signature to see. The transformer now has four explicit options: basepoint, time_augmentation, lead_lag_transform, and rescale. They are applied separately inside every rolling window, so row t still uses no data after t.
window = X[start : t + 1]
if basepoint: window = [(0,...,0)] + window
if time_augmentation: window = append_local_time(window)
if lead_lag_transform: window = lead_lag(window)
signature(window)
Basepoint prepends the origin. Ordinary signatures see increments, so they are translation invariant: without a basepoint, the paths [100, 101, 99] and [0, 1, -1] look identical. Including the origin adds the journey from zero to the first observation when that initial level is meaningful.
Time augmentation appends a local clock running from 0 to 1. It gives the path a monotone coordinate, so the signature can encode how movements sit within a rolling window rather than seeing only their projection onto price space. The clock is local to the window on purpose: its construction needs only the number of already-observed points, not future timestamps. A future timestamp-aware variant can preserve irregular real elapsed time instead.
For a one-dimensional sampled path x0, x1, x2, ..., the lead-lag transform produces the two-dimensional staircase
(x0, x0), (x1, x0), (x1, x1), (x2, x1), (x2, x2), ...
Each price move becomes a horizontal step and then a vertical step. The signed second-level cross terms measure the small rectangles between those steps. Their antisymmetric difference is the area, while their symmetric combination is controlled by the endpoint. Together, they recover the sum of squared increments — the discrete quadratic variation that matters for realised volatility.
This is the central finance reason to include the transform: a level-2 signature of a one-dimensional path cannot see its internal variation beyond its endpoint, but the two-dimensional lead-lag path can. The transformer uses lead_lag_transform=True rather than silently imposing it because the embedding doubles the channel count and feature growth is exponential in signature depth.
The production default remains RoughPy. A small backend="numpy" implementation now exists only to make the algebra executable in tests. For each increment dx, a straight line has signature
The reference multiplies those truncated tensor exponentials one increment at a time. This is Chen's identity as an algorithm: at every level k, sum the tensor products of the old level i and the increment's level k-i. It is intentionally slower than RoughPy, but has two payoffs: it does not depend on a backend API, and it can be checked independently against RoughPy whenever that optional integration is installed.
There is a useful separation of responsibilities here. The NumPy code is not a competing production engine; it is the simple thing we trust enough to catch wrapper mistakes. RoughPy is the fast thing we use for real work.
The current suite verifies causal alignment, output shape and names, parameter validation, basepoint/time/lead-lag construction, per-level factorial rescaling, the level-1 and diagonal level-2 formulae, shuffle identities, and Chen's identity for the independent reference implementation. The two skipped tests need optional external backends (RoughPy and iisignature); they run as cross-backend agreement checks when those packages are available.
The next learning step is log-signatures: why taking the tensor logarithm removes redundant shuffle coordinates and leaves a free-Lie-algebra basis. Chapter 4 does exactly that, and adds the first hypothesis-based property test along the way. After that, the streaming update built from the same Chen product and group inverse is next (Part III).
§1.7's shuffle identity said the tensor coordinates of a signature are not independent — e.g. S¹·S² is fully determined by S¹²+S²¹. Chapter 1 used that as a correctness check. Read the other way, it's a compression opportunity: the depth-k signature of a d-dimensional path has coordinates at level k, but the shuffle relations mean far fewer than that are actually free.
The exact count of free coordinates is given by Witt's formula for the dimension of the free Lie algebra on generators:
(Möbius function , summed over divisors of the level n; implemented as n_log_features in _backend.py.) For the 2-dimensional path this whole notes page has been using:
| depth | tensor coords — n_features(2,depth) | free coords — n_log_features(2,depth) |
|---|---|---|
| 1 | 2 | 2 |
| 2 | 6 | 3 |
| 3 | 14 | 5 |
| 4 | 30 | 8 |
| 5 | 62 | 14 |
§1.6 and §1.7 are two views of one fact. Extend from to all of as an algebra homomorphism (i.e. under concatenation), and is a graded bialgebra. Ree's theorem: a series is grouplike for this (i.e. ) exactly when its coefficients obey the shuffle relations — so §1.7's numerical check was a check of grouplike-ness, stated in coordinates rather than in .
Friedrichs' criterion is the companion fact for the logarithm: if is grouplike then is primitive — — and the primitive elements of this bialgebra are exactly , the free Lie algebra generated by under the commutator . This is the ring-theory payoff promised in CLAUDE.md: grouplike-ness (a statement about the tensor algebra as an associative ring) and Lie-ness (a statement about the same underlying space as a Lie algebra under the commutator) are dual sides of one theorem, not two separate facts bolted together.
Turned into coordinates, primitivity says the exact opposite of §1.7's grouplike identity: instead of a shuffle sum equalling a product, it vanishes. For nonempty words , log-signature coefficients satisfy . On the §1.1 path's log-signature (computed below in §4.3):
| identity | value |
|---|---|
| 2·X112+X121 (shuffle of "1" and "12") | 2.1×10⁻¹⁴ |
| X212+2·X122 (shuffle of "2" and "12") | 5.3×10⁻¹⁵ |
This is exactly the identity test_log_signature_is_primitive_under_shuffle checks with hypothesis in §4.6, over random paths and random word pairs rather than one fixed example.
Write ( has no level-0 part — it's the levels 1..depth already computed in §3.3). Because is nilpotent at any truncation (its -th power has no nonzero terms below level ), the ordinary power series for the logarithm is not an approximation here — it's exact once truncated to depth:
_backend.py's _log_signature_full_numpy computes exactly this, reusing the same level-list / Kronecker-product machinery as §3.3's signature reference (refactored into a shared _tensor_multiply_levels helper — concatenation-as-ring-product, the same operation whether it's building the exponential of an increment or multiplying two partial log-series terms):
def _tensor_multiply_levels(a, b, dim, depth):
out = []
for level in range(depth + 1):
acc = np.zeros(dim**level)
for left in range(level + 1):
acc = acc + np.kron(a[left], b[level - left])
out.append(acc)
return out
def _log_signature_full_numpy(path, depth):
levels = _signature_levels_numpy(path, depth)
x = [np.zeros(1)] + [levels[l].copy() for l in range(1, depth + 1)]
log_levels = [np.zeros(dim**l) for l in range(depth + 1)]
term, sign = x, 1.0
for k in range(1, depth + 1):
for level in range(depth + 1):
log_levels[level] += sign * term[level] / k
if k < depth:
term = _tensor_multiply_levels(term, x, dim, depth)
sign = -sign
return np.concatenate(log_levels[1:])
On the §1.1 path, level 1 is unchanged from the raw signature (the linear term of is just itself — same net displacement as §1.3), and level 2 is now exactly antisymmetric, not merely determined-but-present the way §1.4 found for the raw signature:
| coordinate 1 | coordinate 2 | |
|---|---|---|
| log-sig level 1 | 6.849257 | 2.373225 |
| raw S¹ (from §1.3, for comparison) | 6.849257 | 2.373225 |
| log level 2 | col 1 | col 2 |
|---|---|---|
| row 1 | ≈0 (3.6×10⁻¹⁵) | −7.171706 |
| row 2 | 7.171706 | ≈0 |
to_logsignature_log_signature_full_numpy is honest but wasteful: it hands back the full n_features(dim, depth)-sized redundant representation, not the n_log_features(dim, depth) free coordinates from §4.1. Getting the minimal representation means picking an actual basis of . The standard choice is a Hall basis built from Lyndon words (Chen–Fox–Lyndon theorem): a word is Lyndon if it is strictly smaller, lexicographically, than every one of its own rotations; each Lyndon word factors uniquely into two shorter Lyndon words (its standard factorisation), and bracketing that factorisation recursively gives one basis element per Lyndon word — a genuine basis of , of exactly the dimension §4.1 predicts.
RoughPy computes this rather than us having to: ctx.to_logsignature(sig) is documented as "equivalent to tensor_to_lie(signature.log())" — RoughPy's own words for precisely the log-then-project-to-Lie pipeline just built by hand in §4.3. For the §1.1 path at depth 3, width 2 (5 = n_log_features(2, 3) coordinates, matching §4.1's table):
| basis bracket | value |
|---|---|
| [1] (= word "1") | 6.849257 |
| [2] (= word "2") | 2.373225 |
| [1,2] | −7.171706 |
| [1,[1,2]] | −0.843217 |
| [2,[1,2]] | −5.183520 |
The bracket [1,2]'s value, −7.171706, is exactly half of §1.4's raw signed-area combination : the Lévy area from §1.4 is the level-2 log-signature, up to that factor of 2 from how the bracket is normalised against the raw tensor coordinates.
Both silent, same family as §2.3's.
The obvious way to line a Lie element's coefficients up with lie_basis.index_to_key(i)'s canonical order is to index by key directly. Both routes give wrong answers with no error:
basis.key_to_index(basis.index_to_key(i)) # always returns 5, out of range, for every i
logsig[basis.index_to_key(i)] # returns repeated, wrong values for i ≥ 2
by_label = {str(item.key()): item.value().to_float() for item in logsig}
[by_label.get(str(basis.index_to_key(i)), 0.0) for i in range(basis.dimension)]
Iterating the Lie object and matching on the string form of each key -- str(item.key()) against str(basis.index_to_key(i)) -- was the only approach found that reliably lines values up with the canonical order. Worth flagging because it's easy to be misled by the correct-looking output: logsig[basis.index_to_key(2)] prints as 6.84926, a plausible-looking number (it's actually the level-1 coefficient for word "1"), not an obvious garbage value.
Caught by test_log_signature_matches_bracket_expansion_of_full_tensor_log: expanding the brackets [1,2], [1,[1,2]], [2,[1,2]] by hand as tensor words (e.g. ) and cross-checking against §4.3's independent numpy tensor log turned up index-5-out-of-bounds crashes and silently-wrong values before the string-label workaround was in place — the same pattern as §2.3: treat the algebra as a test, not an assumption, and bugs like this surface immediately instead of contaminating a benchmark three chapters later.
_log_signature_full_numpy isn't a second backend — it returns full redundant tensor coordinates, not the Hall-basis ones, so it's never compared elementwise against log_signature(). Its job is to make the primitivity identity from §4.2 executable as a property test, the log-signature analogue of §1.7's shuffle test for the plain signature:
@given(
path=arrays(dtype=float, shape=(6, 2), elements=st.floats(-5, 5, allow_nan=False)),
u=st.tuples(st.integers(1, 2)),
v=st.tuples(st.integers(1, 2), st.integers(1, 2)),
)
def test_log_signature_is_primitive_under_shuffle(path, u, v):
log_full = dict(zip(_words(2, 3), _log_signature_full_numpy(path, depth=3)))
total = sum(log_full[w] for w in _shuffles(u, v))
assert total == pytest.approx(0.0, abs=1e-6)
This is the first hypothesis-based property test (listed as still open since §2.4): rather than one fixed path, it generates 50 random 6-point paths and random word pairs and checks the shuffle-vanishing identity holds on all of them. It's a meaningfully different check from §4.5's bracket-expansion test — that one catches indexing bugs in the RoughPy extraction; this one catches errors in the log-series arithmetic itself (a wrong sign or a missing /k would break primitivity, but wouldn't necessarily break the single fixed-path bracket-expansion numbers, which is why both exist).
SignatureTransformer gained an output parameter, mirroring §2.2's existing signature() wiring rather than adding a new code path:
compute = log_signature if self.output == "log_signature" else signature
...
out[t] = compute(path, self.depth, backend=self.backend)
get_feature_names_out() returns labels like logsig_[1,[1,2]] instead of sig_112. The causal-alignment guarantee from §2.2 is unchanged and covered by its own test (test_log_signature_causal_alignment) rather than assumed to carry over.
output="log_signature" only works with backend="roughpy" for now. A general-dimension, general-depth numpy Lyndon-basis reduction (rather than just the depth-3, width-2 bracket expansion hand-derived in §4.5) and log-signature support for the iisignature backend were both real gaps as of this chapter — same honesty standard as §1.9's backend spike and §2.4's status table. (The iisignature log-signature backend arrives in Part II, where the benchmark needs it; the general numpy Lyndon reduction was never needed, because the numpy path stayed a test oracle.) Factorial rescale is also not implemented for log-signature output (it would need to be indexed by bracket depth rather than word length) and raises a clear ValueError rather than silently doing the wrong thing.| status | |
|---|---|
| tests | 34 passed (up from 18 in Chapter 3), including the first hypothesis property test |
| log-signature backend | roughpy only; matches the independent numpy oracle at every checked coordinate |
| transformer | output="signature" (default) or "log_signature", same causal guarantee either way |
| still open at this point | general Lyndon-basis reduction in numpy, iisignature log-signature support, rescale for log output, CI, the streaming engine |
The v0.1 plan named five correctness oracles: Chen's identity (§1.6, §3.3), the shuffle identity (§1.7), free-Lie-algebra membership (§4.6's primitivity test), backend agreement (§3.4), and invariance under time reparametrization — the one still open at the end of Chapter 4. It says: resampling the same trajectory more densely must not change its signature, because the signature is meant to characterize a path geometrically, not the clock used to record it.
For a piecewise-linear path this is exact, not approximate, and the reason is algebraic rather than numerical. Splitting a straight segment into two collinear sub-segments a and b (both scalar multiples of the same direction vector) means every tensor power of a and b commutes — whenever a and b point the same way — so the truncated exponentials multiply the way ordinary scalar exponentials do:
the same identity §1.6/§3.3 use for concatenating unrelated increments, specialized to the case where the increments happen to be parallel. test_signature_invariant_under_time_reparametrization (tests/test_backend.py) turns this into a hypothesis property test: it generates random 5-point paths and random sets of extra points inserted along existing segments, and asserts the resampled path's signature matches the original's to 1e-8 — exact equality up to floating-point roundoff, not a loose tolerance standing in for an approximation.
All five oracles are now implemented. That closes the open-items line carried forward since §2.4.
Every identity in this document was, until now, something proven by running pytest locally and trusting the last run stayed valid. .github/workflows/ci.yml removes that trust requirement: every push to main and every pull request installs rollsig with its test and iisignature extras and runs the full suite on Python 3.11 and 3.12. If a future change breaks Chen's identity or causal alignment, it shows up on the PR, not three chapters later.
Also decided here: no separate docs/math.md. The plan asked for a short expository note, but this document already is that note, written chapter by chapter as the code was built rather than distilled after the fact — a second file would only duplicate it out of sync.
| status | |
|---|---|
| tests | 35 passed, enforced in CI on Python 3.11 and 3.12 |
| correctness oracles | all five planned for v0.1: Chen's identity, shuffle identity, primitivity/free Lie algebra, time-reparametrization invariance, backend agreement |
| transformer | signature or log_signature output, causal by construction and by test, with basepoint/time-augmentation/lead-lag/rescale preprocessing |
| math write-up | this document; no separate docs/math.md |
| still open at the end of Part I | general Lyndon-basis reduction in numpy, iisignature log-signature support (Part II builds it), rescale for log output, the streaming engine (Part III builds it) |
v0.2 is next: the Optiver realized-volatility benchmark against reproduced baselines. Unlike this chapter's closed algebraic identities, that one ends in a number that could go either way — the honesty constraint applies there directly.