# TensorInteractionBasis


Tensor product interaction basis (`ti()`-style pure interaction smooth).


Usage

``` python
TensorInteractionBasis(marginals)
```


[TensorInteractionBasis](TensorInteractionBasis.md#whittaker.TensorInteractionBasis) builds a tensor product smooth like [TensorProductBasis](TensorProductBasis.md#whittaker.TensorProductBasis), but first projects each marginal basis onto its own penalty *range space* (removing the marginal's null-space components, such as the constant and linear terms) before taking the tensor product. The result spans only the pure interaction between the covariates -- none of the lower-order main-effect structure that a plain tensor product basis would otherwise reintroduce. This makes it the right building block for ANOVA-style decompositions of a smooth surface into orthogonal pieces, e.g. `s(x1) + s(x2) + ti(x1, x2)`, where `s(x1)` and `s(x2)` already carry the main effects and `ti(x1, x2)` is meant to add only what a sum of the two one-dimensional smooths cannot represent. Use [TensorInteractionBasis](TensorInteractionBasis.md#whittaker.TensorInteractionBasis) instead of [TensorProductBasis](TensorProductBasis.md#whittaker.TensorProductBasis) whenever main effects are (or will be) modeled by separate marginal smooths and double-counting of the main-effect structure inside the interaction term must be avoided; use [TensorProductBasis](TensorProductBasis.md#whittaker.TensorProductBasis) or [TensorProductBasisT2](TensorProductBasisT2.md#whittaker.TensorProductBasisT2) when the interaction term is meant to stand alone and include the main effects itself.


## Parameters


`marginals: list[SmoothBasis]`  
List of (unfitted) marginal basis objects, one per covariate dimension, e.g. `[TPRS(k=10), TPRS(k=8)]`. At least 2 marginals are required.


## Notes

For each marginal basis with penalty `S_j`, `fit()` eigendecomposes `S_j` and keeps only the eigenvectors `U_j` with (numerically) positive eigenvalues -- the penalized *range space* -- while discarding the null-space eigenvectors (the unpenalized polynomials, dimension `M_j`). Each marginal's basis matrix is then reprojected onto this reduced space, `B_j' = B_j U_j`, of dimension `r_j = k_j - M_j` rather than the original `k_j`, before the marginals are combined with the same row-wise Kronecker product used by [TensorProductBasis](TensorProductBasis.md#whittaker.TensorProductBasis):

 \mathbf{B}\[i, :\] = \mathbf{B}\_1'\[i, :\] \otimes \mathbf{B}\_2'\[i, :\] \otimes \cdots \otimes \mathbf{B}\_d'\[i, :\]. 

Because every marginal contributes only its range space, none of the columns of `B` correspond to a main-effect direction, and the total basis dimension is the product of the range-space dimensions, `k = r_1 \cdot r_2 \cdots r_d`, smaller than the `k_1 \cdots k_d` used by [TensorProductBasis](TensorProductBasis.md#whittaker.TensorProductBasis) on the same marginals. In this reduced basis, each marginal's penalty is already diagonal (it is expressed in its own eigenbasis), `\operatorname{diag}(d_{j,1}, \ldots, d_{j,r_j})` for the retained eigenvalues `d_{j,i}`, and the per-direction penalty matrices are the Kronecker products

 \mathbf{S}\_j^{\text{ti}} = \mathbf{I}\_{r_1} \otimes \cdots \otimes \operatorname{diag}(d\_{j, 1}, \ldots, d\_{j, r_j}) \otimes \cdots \otimes \mathbf{I}\_{r_d}, 

exactly analogous to [TensorProductBasis.penalty_matrices()](TensorProductBasis.md#whittaker.TensorProductBasis.penalty_matrices) but operating in the range-space coordinates. Since every retained coefficient direction is, by construction, in some marginal's range space and therefore penalized by at least one of these matrices, `null_space_dimension()` is always `0` for the interaction basis itself. Note that the eigendecomposition used to find each marginal's range space assumes the marginal penalty has a well-separated null space (a numerical tolerance of `1e-10` relative to the largest eigenvalue is used to distinguish "zero"); for marginal bases with unusual or nearly-singular penalties this tolerance may need revisiting.


## Examples


``` python
import numpy as np
from whittaker.smooths.tensor import TensorInteractionBasis
from whittaker.smooths.tprs import TPRS

rng = np.random.default_rng(0)
x = rng.uniform(0, 1, (100, 2))

basis = TensorInteractionBasis([TPRS(k=6), TPRS(k=5)]).fit(x)
B = basis.basis_matrix(x)
penalties = basis.penalty_matrices()
B.shape, len(penalties)
```


    ((100, 12), 2)


## Attributes

| Name | Description |
|----|----|
| [marginals](#marginals) | The list of marginal basis objects, fitted after `fit()` is called. |
| [n_basis](#n_basis) | Total number of basis functions. |

------------------------------------------------------------------------


### marginals


The list of marginal basis objects, fitted after `fit()` is called.


`marginals: list[SmoothBasis]`


Each marginal is fit independently to its own covariate column; its basis matrix is then projected onto its own penalty range space before being combined with the others via the row-wise Kronecker product to form `basis_matrix()`.


------------------------------------------------------------------------


### n_basis


Total number of basis functions.


`n_basis: int`


Equal to the product `r_1 * r_2 * ... * r_d` of the marginal *range-space* dimensions (each marginal's basis dimension minus its null-space dimension), since main-effect directions are projected out before the marginals are combined.


## Methods

| Name | Description |
|----|----|
| [basis_matrix()](#basis_matrix) | Evaluate the pure-interaction tensor basis at `x`. |
| [fit()](#fit) | Fit each marginal basis, then decompose its penalty into null and range spaces. |
| [identifiability_constraints()](#identifiability_constraints) | Return a sum-to-zero constraint on the interaction basis, if derivable. |
| [null_space_dimension()](#null_space_dimension) | Return the dimension of the basis's unpenalized null space. |
| [penalty_matrices()](#penalty_matrices) | Return one penalty matrix per marginal direction, in range-space coordinates. |
| [penalty_matrix()](#penalty_matrix) | Sum of all per-direction penalty matrices, for single-penalty compatibility. |

------------------------------------------------------------------------


### basis_matrix()


Evaluate the pure-interaction tensor basis at `x`.


Usage

``` python
basis_matrix(x)
```


Projects each marginal's basis matrix onto its own penalty range space (dropping the marginal's null-space/main-effect columns) before combining them with the row-wise Kronecker product.


#### Parameters


`x: NDArray`  
Covariate values, shape `(n, d)` where [d](TPRS.md#whittaker.TPRS.d) matches the number of marginals.


#### Returns


`NDArray`  
Design matrix of shape `(n, k)` where `k = self.n_basis` is the product of the marginal range-space dimensions.


------------------------------------------------------------------------


### fit()


Fit each marginal basis, then decompose its penalty into null and range spaces.


Usage

``` python
fit(x)
```


For each marginal, fits the marginal basis to its column of `x`, eigendecomposes the marginal's penalty matrix, and retains the eigenvectors with positive eigenvalues (the range space) for use by `basis_matrix()` and `penalty_matrices()`.


#### Parameters


`x: NDArray`  
Training covariates, shape `(n, d)` where [d](TPRS.md#whittaker.TPRS.d) is the number of marginals.


#### Returns


`TensorInteractionBasis`  
Returns `self` for method chaining.


#### Raises


`ValueError`  
If the number of columns in `x` does not match the number of marginal bases.


------------------------------------------------------------------------


### identifiability_constraints()


Return a sum-to-zero constraint on the interaction basis, if derivable.


Usage

``` python
identifiability_constraints()
```


Attempts to recover each marginal's training covariate values (via a `_x_train` or `_knots` attribute) to build a constraint that forces the interaction surface to have mean zero over the training data. If any marginal does not expose its training data in a recognized form, no constraint can be derived and `None` is returned.


#### Returns


`NDArray or None`  
A `(1, k)` matrix of the mean basis-function values over the training grid, or `None` if the training covariates could not be recovered from the marginals.


------------------------------------------------------------------------


### null_space_dimension()


Return the dimension of the basis's unpenalized null space.


Usage

``` python
null_space_dimension()
```


Because every marginal contributes only its penalty range space (the null-space components, such as constants and linear terms, were discarded during `fit()`), every retained coefficient direction is penalized by at least one of the matrices returned by `penalty_matrices()`. This is always `0` for the pure-interaction basis.


#### Returns


`int`  
Always `0`.


------------------------------------------------------------------------


### penalty_matrices()


Return one penalty matrix per marginal direction, in range-space coordinates.


Usage

``` python
penalty_matrices()
```


Because each marginal's basis has already been reprojected onto its penalty range space during `fit()`, the marginal penalty in that space is simply the diagonal matrix of its retained eigenvalues; this method Kronecker-expands that diagonal matrix into the full interaction basis exactly as [TensorProductBasis.penalty_matrices()](TensorProductBasis.md#whittaker.TensorProductBasis.penalty_matrices) does for the ordinary (non-reprojected) basis.


#### Returns


`list[NDArray]`  
List of [d](TPRS.md#whittaker.TPRS.d) matrices, each of shape `(k, k)` where [d](TPRS.md#whittaker.TPRS.d) is the number of marginals and `k` is the total (range-space) basis dimension.


------------------------------------------------------------------------


### penalty_matrix()


Sum of all per-direction penalty matrices, for single-penalty compatibility.


Usage

``` python
penalty_matrix()
```


#### Returns


`NDArray`  
The `(k, k)` sum of all matrices returned by `penalty_matrices()`.
