# TensorProductBasis


Tensor product of marginal smooth bases (`te()`-style interaction smooth).


Usage

``` python
TensorProductBasis(marginals)
```


A tensor product smooth builds a multivariate smooth function of two or more covariates out of one-dimensional (or otherwise lower-dimensional) marginal smooths, one per covariate, by taking the row-wise outer product of their basis matrices. This is the standard way to represent an interaction between covariates that are measured on very different scales or units (e.g. a spatial coordinate combined with time, or a covariate in meters combined with one in years) -- unlike an isotropic basis such as [TPRS](TPRS.md#whittaker.TPRS), which assumes all covariates share a common notion of distance, a tensor product smooth applies a separate marginal penalty (and, in principle, a separate smoothing parameter) to each covariate direction, so that the anisotropic scaling of the covariates does not distort the fitted surface. Choose [TensorProductBasis](TensorProductBasis.md#whittaker.TensorProductBasis) over [TPRS](TPRS.md#whittaker.TPRS) whenever the covariates involved are not naturally on comparable scales, and use [TensorInteractionBasis](TensorInteractionBasis.md#whittaker.TensorInteractionBasis) instead when a decomposition into separate main-effect and pure-interaction terms (an ANOVA-style model) is wanted.


## Parameters


`marginals: list[SmoothBasis]`  
List of (typically unfitted) marginal basis objects, one per covariate dimension to be combined, e.g. `[TPRS(k=10), TPRS(k=8)]` for a bivariate smooth. Each marginal is fit independently to its own column of `x` inside `fit()`. At least 2 marginals are required; for a single covariate, use the marginal basis directly instead of wrapping it in a tensor product.


## Notes

Given [d](TPRS.md#whittaker.TPRS.d) marginal bases with basis matrices `B_1, \ldots, B_d` (each `B_j` of shape `(n, k_j)`), the tensor product basis matrix is the row-wise Kronecker product

 \mathbf{B}\[i, :\] = \mathbf{B}\_1\[i, :\] \otimes \mathbf{B}\_2\[i, :\] \otimes \cdots \otimes \mathbf{B}\_d\[i, :\], \qquad i = 1, \ldots, n, 

which has `k = k_1 k_2 \cdots k_d` columns in total -- every combination of one marginal basis function from each dimension. This basis represents *all* smooth functions expressible as sums of products of the marginal bases, including both pure main effects and their interaction, so unlike [TensorInteractionBasis](TensorInteractionBasis.md#whittaker.TensorInteractionBasis), no separate main-effect terms need to be added to the model for identifiability of low-order structure (though it is common practice in mgcv-style formulas to do so anyway for a cleaner ANOVA decomposition). For each marginal direction `j` with own penalty `S_j`, the tensor product carries one whole-basis penalty per marginal direction,

 \mathbf{S}\_j^{\text{tensor}} = \mathbf{I}\_{k_1} \otimes \cdots \otimes \mathbf{I}\_{k\_{j-1}} \otimes \mathbf{S}\_j \otimes \mathbf{I}\_{k\_{j+1}} \otimes \cdots \otimes \mathbf{I}\_{k_d}, 

returned in order by `penalty_matrices()`; `penalty_matrix()` sums these into a single matrix only for compatibility with the base [SmoothBasis](SmoothBasis.md#whittaker.SmoothBasis) interface -- for a proper anisotropic fit (one smoothing parameter per marginal direction), use `penalty_matrices()` directly rather than `penalty_matrix()`. The null-space dimension of the combined penalty is the product of the marginal null-space dimensions, `M = M_1 \cdot M_2 \cdots M_d` (the multivariate polynomials that are simultaneously in the null space of every marginal penalty). Because `k` grows multiplicatively with the number of marginals and their individual sizes, tensor products become expensive quickly in more than two or three dimensions; for higher-dimensional smooths of covariates on comparable scales, an isotropic basis such as [TPRS](TPRS.md#whittaker.TPRS) is usually preferable.


## Examples


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

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

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


    ((100, 30), 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; their basis matrices are combined via the row-wise Kronecker product to form `basis_matrix()`.


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


### n_basis


Total number of basis functions.


`n_basis: int`


Equal to the product `k_1 * k_2 * ... * k_d` of the marginal basis dimensions, since every combination of one marginal basis function from each dimension contributes one column of the tensor product basis matrix.


## Methods

| Name | Description |
|----|----|
| [basis_matrix()](#basis_matrix) | Evaluate the tensor product basis at `x`. |
| [fit()](#fit) | Fit each marginal basis to its corresponding column of `x`. |
| [identifiability_constraints()](#identifiability_constraints) | Return a sum-to-zero constraint on the tensor product basis, if derivable. |
| [null_space_dimension()](#null_space_dimension) | Return the product of the marginal null-space dimensions. |
| [penalty_matrices()](#penalty_matrices) | Return one penalty matrix per marginal direction. |
| [penalty_matrix()](#penalty_matrix) | Sum of all marginal penalty matrices, for single-penalty compatibility. |

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


### basis_matrix()


Evaluate the tensor product basis at `x`.


Usage

``` python
basis_matrix(x)
```


Builds each marginal's basis matrix independently and combines them with the row-wise Kronecker (tensor) product, so that column `j1 * k_2 * ... * k_d + j2 * k_3 * ... + ...` of the result is the product of the corresponding marginal basis function values.


#### 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 basis dimensions.


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


### fit()


Fit each marginal basis to its corresponding column of `x`.


Usage

``` python
fit(x)
```


#### Parameters


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


#### Returns


`TensorProductBasis`  
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 tensor product 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 tensor surface to have mean zero over the training data. If no marginal exposes 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 product of the marginal null-space dimensions.


Usage

``` python
null_space_dimension()
```


The multivariate null space of a tensor product penalty is spanned by the tensor products of each marginal's own null-space basis functions (e.g. for two thin plate marginals with linear null spaces, the constant, and each marginal's linear term, giving a bilinear null space), so its dimension multiplies rather than adds across marginals.


#### Returns


`int`  
`M = M_1 * M_2 * ... * M_d`.


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


### penalty_matrices()


Return one penalty matrix per marginal direction.


Usage

``` python
penalty_matrices()
```


For [d](TPRS.md#whittaker.TPRS.d) marginals with basis dimensions `k_1, ..., k_d` and marginal penalties `S_1, ..., S_d`, penalty `j` is the Kronecker product `I_{k_1} ⊗ ... ⊗ S_j ⊗ ... ⊗ I_{k_d}` (the marginal penalty in position `j`, identity elsewhere), so that penalizing with matrix `j` alone penalizes exactly the roughness of the tensor surface along covariate direction `j`, holding the other directions fixed.


#### Returns


`list[NDArray]`  
List of [d](TPRS.md#whittaker.TPRS.d) matrices, each of shape `(k, k)` where `k` is the total basis dimension. Intended to be combined with one smoothing parameter per marginal direction during model fitting.


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


### penalty_matrix()


Sum of all marginal penalty matrices, for single-penalty compatibility.


Usage

``` python
penalty_matrix()
```


This collapses the per-direction penalties returned by `penalty_matrices()` into one matrix, which is convenient when a caller only supports a single penalty but discards the ability to give each marginal direction its own smoothing parameter. Prefer `penalty_matrices()` for a proper anisotropic fit.


#### Returns


`NDArray`  
The `(k, k)` sum of all per-marginal penalty matrices.
