dfm-python 0.6.12__py3-none-any.whl → 0.6.13__py3-none-any.whl
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.
- dfm_python/__init__.py +1 -1
- dfm_python/dataset/ddfm_dataset.py +4 -2
- dfm_python/layer/mlp.py +272 -0
- dfm_python/layer/pca.py +304 -0
- dfm_python/models/base.py +51 -1
- dfm_python/models/ddfm/__init__.py +12 -0
- dfm_python/models/ddfm/ddfm.py +1 -1
- dfm_python/models/ddfm/decoder.py +263 -0
- dfm_python/{encoder/simple_autoencoder.py → models/ddfm/encoder.py} +204 -43
- dfm_python/models/dfm/initialization.py +1 -1
- dfm_python/models/dfm/mixed_freq.py +24 -3
- dfm_python/models/ivdfm/decoder.py +0 -0
- dfm_python/models/ivdfm/encoder.py +0 -0
- dfm_python/models/ivdfm/ivdfm.py +41 -128
- dfm_python/utils/__init__.py +2 -2
- dfm_python/utils/loss.py +232 -2
- {dfm_python-0.6.12.dist-info → dfm_python-0.6.13.dist-info}/METADATA +1 -1
- {dfm_python-0.6.12.dist-info → dfm_python-0.6.13.dist-info}/RECORD +20 -21
- dfm_python/decoder/__init__.py +0 -16
- dfm_python/decoder/base.py +0 -78
- dfm_python/decoder/linear.py +0 -72
- dfm_python/decoder/mlp.py +0 -128
- dfm_python/encoder/__init__.py +0 -38
- dfm_python/encoder/base.py +0 -111
- dfm_python/encoder/pca.py +0 -271
- /dfm_python/{encoder/variational_autoencoder.py → logger/ivdfm_logger.py} +0 -0
- {dfm_python-0.6.12.dist-info → dfm_python-0.6.13.dist-info}/WHEEL +0 -0
dfm_python/__init__.py
CHANGED
|
@@ -63,7 +63,7 @@ Note: DFMConfig uses frequency dict to specify series (column names -> frequenci
|
|
|
63
63
|
For detailed documentation, see the README.md file and the tutorial notebooks/scripts.
|
|
64
64
|
"""
|
|
65
65
|
|
|
66
|
-
__version__ = "0.
|
|
66
|
+
__version__ = "0.6.13"
|
|
67
67
|
|
|
68
68
|
# ============================================================================
|
|
69
69
|
# PUBLIC API DEFINITION
|
|
@@ -111,8 +111,10 @@ class DDFMDataset(Dataset):
|
|
|
111
111
|
|
|
112
112
|
@property
|
|
113
113
|
def target_indices(self) -> np.ndarray:
|
|
114
|
-
"""Target series column indices in
|
|
115
|
-
|
|
114
|
+
"""Target series column indices in processed data (self.data, excluding time_idx)."""
|
|
115
|
+
# target_series are column names in the processed data (self.data)
|
|
116
|
+
# self.data contains X + y (or just y), but not time_idx
|
|
117
|
+
return np.array([self.data.columns.get_loc(col) for col in self.target_series])
|
|
116
118
|
|
|
117
119
|
@classmethod
|
|
118
120
|
def from_dataset(cls, new_data: Union[pd.DataFrame, PolarsDataFrame], dataset: 'DDFMDataset') -> 'DDFMDataset':
|
dfm_python/layer/mlp.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Multi-Layer Perceptron (MLP) for common usage across models.
|
|
2
|
+
|
|
3
|
+
This module provides a flexible MLP implementation that can be used
|
|
4
|
+
for encoders, decoders, and other neural network components.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from numbers import Number
|
|
8
|
+
from typing import Union, List, Optional
|
|
9
|
+
import torch
|
|
10
|
+
import torch.nn as nn
|
|
11
|
+
import torch.nn.functional as F
|
|
12
|
+
|
|
13
|
+
from ..config.constants import (
|
|
14
|
+
DEFAULT_XAVIER_GAIN,
|
|
15
|
+
DEFAULT_ZERO_VALUE,
|
|
16
|
+
)
|
|
17
|
+
from ..logger import get_logger
|
|
18
|
+
from ..utils.errors import ConfigurationError
|
|
19
|
+
|
|
20
|
+
_logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MLP(nn.Module):
|
|
24
|
+
"""Multi-layer perceptron with flexible architecture.
|
|
25
|
+
|
|
26
|
+
Supports various activation functions, custom hidden dimensions,
|
|
27
|
+
and proper weight initialization.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
input_dim: int,
|
|
33
|
+
output_dim: int,
|
|
34
|
+
hidden_dim: Union[int, List[int]] = 200,
|
|
35
|
+
n_layers: int = 3,
|
|
36
|
+
activation: Union[str, List[str]] = 'relu',
|
|
37
|
+
slope: float = 0.1,
|
|
38
|
+
use_bias: bool = True,
|
|
39
|
+
dropout: float = 0.0,
|
|
40
|
+
use_batch_norm: bool = False,
|
|
41
|
+
device: Optional[Union[str, torch.device]] = None,
|
|
42
|
+
seed: Optional[int] = None,
|
|
43
|
+
):
|
|
44
|
+
"""Initialize MLP.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
input_dim : int
|
|
49
|
+
Input dimension
|
|
50
|
+
output_dim : int
|
|
51
|
+
Output dimension
|
|
52
|
+
hidden_dim : Union[int, List[int]]
|
|
53
|
+
Hidden layer dimension(s). If int, all hidden layers use this dimension.
|
|
54
|
+
If list, specifies dimension for each hidden layer.
|
|
55
|
+
n_layers : int
|
|
56
|
+
Total number of layers (including input and output)
|
|
57
|
+
activation : Union[str, List[str]]
|
|
58
|
+
Activation function(s). Options: 'relu', 'lrelu', 'tanh', 'sigmoid', 'none'.
|
|
59
|
+
If str, all layers use this activation. If list, specifies activation per layer.
|
|
60
|
+
slope : float
|
|
61
|
+
Slope for leaky ReLU (negative_slope parameter)
|
|
62
|
+
use_bias : bool
|
|
63
|
+
Whether to use bias in linear layers
|
|
64
|
+
dropout : float
|
|
65
|
+
Dropout probability (0.0 = no dropout)
|
|
66
|
+
use_batch_norm : bool
|
|
67
|
+
Whether to use batch normalization after each hidden layer
|
|
68
|
+
device : Optional[Union[str, torch.device]]
|
|
69
|
+
Device to move model to (None = no move)
|
|
70
|
+
seed : Optional[int]
|
|
71
|
+
Random seed for weight initialization
|
|
72
|
+
"""
|
|
73
|
+
super().__init__()
|
|
74
|
+
|
|
75
|
+
self.input_dim = input_dim
|
|
76
|
+
self.output_dim = output_dim
|
|
77
|
+
self.n_layers = n_layers
|
|
78
|
+
self.use_bias = use_bias
|
|
79
|
+
self.dropout = dropout
|
|
80
|
+
self.use_batch_norm = use_batch_norm
|
|
81
|
+
|
|
82
|
+
# Validate and set hidden dimensions
|
|
83
|
+
if isinstance(hidden_dim, Number):
|
|
84
|
+
if n_layers < 2:
|
|
85
|
+
raise ValueError(f"n_layers must be >= 2 when hidden_dim is int, got {n_layers}")
|
|
86
|
+
self.hidden_dim = [int(hidden_dim)] * (n_layers - 1)
|
|
87
|
+
elif isinstance(hidden_dim, list):
|
|
88
|
+
if len(hidden_dim) != n_layers - 1:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"hidden_dim list length ({len(hidden_dim)}) must equal n_layers - 1 ({n_layers - 1})"
|
|
91
|
+
)
|
|
92
|
+
self.hidden_dim = [int(d) for d in hidden_dim]
|
|
93
|
+
else:
|
|
94
|
+
raise ValueError(f'Wrong argument type for hidden_dim: {type(hidden_dim)}')
|
|
95
|
+
|
|
96
|
+
# Validate and set activations
|
|
97
|
+
if isinstance(activation, str):
|
|
98
|
+
self.activation = [activation] * (n_layers - 1)
|
|
99
|
+
elif isinstance(activation, list):
|
|
100
|
+
if len(activation) != n_layers - 1:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"activation list length ({len(activation)}) must equal n_layers - 1 ({n_layers - 1})"
|
|
103
|
+
)
|
|
104
|
+
self.activation = activation
|
|
105
|
+
else:
|
|
106
|
+
raise ValueError(f'Wrong argument type for activation: {type(activation)}')
|
|
107
|
+
|
|
108
|
+
# Set random seed if provided
|
|
109
|
+
if seed is not None:
|
|
110
|
+
torch.manual_seed(seed)
|
|
111
|
+
|
|
112
|
+
# Build activation functions
|
|
113
|
+
self._act_f = []
|
|
114
|
+
for act in self.activation:
|
|
115
|
+
if act == 'lrelu':
|
|
116
|
+
self._act_f.append(lambda x, s=slope: F.leaky_relu(x, negative_slope=s))
|
|
117
|
+
elif act == 'relu':
|
|
118
|
+
self._act_f.append(F.relu)
|
|
119
|
+
elif act == 'tanh':
|
|
120
|
+
self._act_f.append(torch.tanh)
|
|
121
|
+
elif act == 'sigmoid':
|
|
122
|
+
self._act_f.append(torch.sigmoid)
|
|
123
|
+
elif act == 'none' or act is None:
|
|
124
|
+
self._act_f.append(lambda x: x)
|
|
125
|
+
else:
|
|
126
|
+
raise ValueError(f'Incorrect activation: {act}. Options: relu, lrelu, tanh, sigmoid, none')
|
|
127
|
+
|
|
128
|
+
# Build layers
|
|
129
|
+
self.layers = nn.ModuleList()
|
|
130
|
+
self.batch_norms = nn.ModuleList() if use_batch_norm else None
|
|
131
|
+
|
|
132
|
+
if n_layers == 1:
|
|
133
|
+
# Single layer: input -> output
|
|
134
|
+
layer = nn.Linear(input_dim, output_dim, bias=use_bias)
|
|
135
|
+
self._init_linear(layer)
|
|
136
|
+
self.layers.append(layer)
|
|
137
|
+
else:
|
|
138
|
+
# First hidden layer: input -> hidden[0]
|
|
139
|
+
layer = nn.Linear(input_dim, self.hidden_dim[0], bias=use_bias)
|
|
140
|
+
self._init_linear(layer)
|
|
141
|
+
self.layers.append(layer)
|
|
142
|
+
|
|
143
|
+
if use_batch_norm:
|
|
144
|
+
self.batch_norms.append(nn.BatchNorm1d(self.hidden_dim[0]))
|
|
145
|
+
|
|
146
|
+
# Hidden layers: hidden[i-1] -> hidden[i]
|
|
147
|
+
for i in range(1, len(self.hidden_dim)):
|
|
148
|
+
layer = nn.Linear(self.hidden_dim[i - 1], self.hidden_dim[i], bias=use_bias)
|
|
149
|
+
self._init_linear(layer)
|
|
150
|
+
self.layers.append(layer)
|
|
151
|
+
|
|
152
|
+
if use_batch_norm:
|
|
153
|
+
self.batch_norms.append(nn.BatchNorm1d(self.hidden_dim[i]))
|
|
154
|
+
|
|
155
|
+
# Output layer: hidden[-1] -> output
|
|
156
|
+
layer = nn.Linear(self.hidden_dim[-1], output_dim, bias=use_bias)
|
|
157
|
+
self._init_linear(layer, is_output=True)
|
|
158
|
+
self.layers.append(layer)
|
|
159
|
+
|
|
160
|
+
# Dropout layer
|
|
161
|
+
if dropout > 0.0:
|
|
162
|
+
self.dropout_layer = nn.Dropout(dropout)
|
|
163
|
+
else:
|
|
164
|
+
self.dropout_layer = None
|
|
165
|
+
|
|
166
|
+
# Move to device if specified
|
|
167
|
+
if device is not None:
|
|
168
|
+
if isinstance(device, str):
|
|
169
|
+
device = torch.device(device)
|
|
170
|
+
self.to(device)
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _init_linear(layer: nn.Linear, is_output: bool = False) -> None:
|
|
174
|
+
"""Initialize linear layer weights and bias.
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
layer : nn.Linear
|
|
179
|
+
Linear layer to initialize
|
|
180
|
+
is_output : bool
|
|
181
|
+
Whether this is the output layer (uses different initialization)
|
|
182
|
+
"""
|
|
183
|
+
# Weight initialization
|
|
184
|
+
if is_output:
|
|
185
|
+
# Output layer: Xavier normal with default gain
|
|
186
|
+
nn.init.xavier_normal_(layer.weight, gain=DEFAULT_XAVIER_GAIN)
|
|
187
|
+
else:
|
|
188
|
+
# Hidden layers: Xavier normal
|
|
189
|
+
nn.init.xavier_normal_(layer.weight, gain=DEFAULT_XAVIER_GAIN)
|
|
190
|
+
|
|
191
|
+
# Bias initialization
|
|
192
|
+
if layer.bias is not None:
|
|
193
|
+
nn.init.constant_(layer.bias, DEFAULT_ZERO_VALUE)
|
|
194
|
+
|
|
195
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
196
|
+
"""Forward pass through MLP.
|
|
197
|
+
|
|
198
|
+
Parameters
|
|
199
|
+
----------
|
|
200
|
+
x : torch.Tensor
|
|
201
|
+
Input tensor, shape (batch, input_dim) or (batch, *, input_dim)
|
|
202
|
+
|
|
203
|
+
Returns
|
|
204
|
+
-------
|
|
205
|
+
torch.Tensor
|
|
206
|
+
Output tensor, shape (batch, output_dim) or (batch, *, output_dim)
|
|
207
|
+
"""
|
|
208
|
+
# Handle multi-dimensional input (e.g., sequences)
|
|
209
|
+
original_shape = x.shape
|
|
210
|
+
if x.dim() > 2:
|
|
211
|
+
# Flatten all dimensions except last
|
|
212
|
+
x = x.view(-1, x.shape[-1])
|
|
213
|
+
needs_reshape = True
|
|
214
|
+
else:
|
|
215
|
+
needs_reshape = False
|
|
216
|
+
|
|
217
|
+
# Forward through layers
|
|
218
|
+
h = x
|
|
219
|
+
n_hidden = len(self.layers) - 1 # Number of hidden layers
|
|
220
|
+
|
|
221
|
+
for i, layer in enumerate(self.layers):
|
|
222
|
+
h = layer(h)
|
|
223
|
+
|
|
224
|
+
# Apply activation, batch norm, and dropout (except on output layer)
|
|
225
|
+
if i < n_hidden:
|
|
226
|
+
# Activation
|
|
227
|
+
h = self._act_f[i](h)
|
|
228
|
+
|
|
229
|
+
# Batch normalization
|
|
230
|
+
if self.use_batch_norm and self.batch_norms is not None:
|
|
231
|
+
h = self.batch_norms[i](h)
|
|
232
|
+
|
|
233
|
+
# Dropout
|
|
234
|
+
if self.dropout_layer is not None:
|
|
235
|
+
h = self.dropout_layer(h)
|
|
236
|
+
|
|
237
|
+
# Reshape if needed
|
|
238
|
+
if needs_reshape:
|
|
239
|
+
output_shape = list(original_shape[:-1]) + [self.output_dim]
|
|
240
|
+
h = h.view(*output_shape)
|
|
241
|
+
|
|
242
|
+
return h
|
|
243
|
+
|
|
244
|
+
def get_num_parameters(self) -> int:
|
|
245
|
+
"""Get total number of trainable parameters.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
int
|
|
250
|
+
Number of trainable parameters
|
|
251
|
+
"""
|
|
252
|
+
return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
|
253
|
+
|
|
254
|
+
def get_layer_info(self) -> dict:
|
|
255
|
+
"""Get information about MLP architecture.
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
dict
|
|
260
|
+
Dictionary with architecture information
|
|
261
|
+
"""
|
|
262
|
+
return {
|
|
263
|
+
'input_dim': self.input_dim,
|
|
264
|
+
'output_dim': self.output_dim,
|
|
265
|
+
'hidden_dims': self.hidden_dim,
|
|
266
|
+
'n_layers': self.n_layers,
|
|
267
|
+
'activations': self.activation,
|
|
268
|
+
'use_bias': self.use_bias,
|
|
269
|
+
'dropout': self.dropout,
|
|
270
|
+
'use_batch_norm': self.use_batch_norm,
|
|
271
|
+
'num_parameters': self.get_num_parameters(),
|
|
272
|
+
}
|
dfm_python/layer/pca.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Principal Component Analysis (PCA) utilities.
|
|
2
|
+
|
|
3
|
+
This module provides pure PCA functions for factor extraction.
|
|
4
|
+
Similar to MLP, this is a utility layer without class inheritance.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from typing import Tuple, Optional, Union, TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
from ..logger import get_logger
|
|
14
|
+
from ..numeric.stability import create_scaled_identity
|
|
15
|
+
from ..config.constants import DEFAULT_IDENTITY_SCALE
|
|
16
|
+
from ..config.types import to_numpy
|
|
17
|
+
|
|
18
|
+
_logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compute_principal_components(
|
|
22
|
+
cov_matrix: Union[np.ndarray, "torch.Tensor"],
|
|
23
|
+
n_components: int,
|
|
24
|
+
block_idx: Optional[int] = None
|
|
25
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
26
|
+
"""Compute top principal components via eigendecomposition (NumPy-based).
|
|
27
|
+
|
|
28
|
+
Accepts both NumPy arrays and PyTorch tensors, but performs all
|
|
29
|
+
computations in NumPy for consistency with the refactored codebase.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
cov_matrix : np.ndarray or torch.Tensor
|
|
34
|
+
Covariance matrix (N x N)
|
|
35
|
+
n_components : int
|
|
36
|
+
Number of principal components to extract
|
|
37
|
+
block_idx : int, optional
|
|
38
|
+
Block index for error messages
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
eigenvalues : np.ndarray
|
|
43
|
+
Eigenvalues (n_components,)
|
|
44
|
+
eigenvectors : np.ndarray
|
|
45
|
+
Eigenvectors (N x n_components)
|
|
46
|
+
"""
|
|
47
|
+
# Convert to NumPy if needed
|
|
48
|
+
cov_matrix = to_numpy(cov_matrix)
|
|
49
|
+
|
|
50
|
+
if cov_matrix.size == 1:
|
|
51
|
+
eigenvector = np.array([[DEFAULT_IDENTITY_SCALE]])
|
|
52
|
+
eigenvalue = cov_matrix[0, 0] if np.isfinite(cov_matrix[0, 0]) else DEFAULT_IDENTITY_SCALE
|
|
53
|
+
return np.array([eigenvalue]), eigenvector
|
|
54
|
+
|
|
55
|
+
n_series = cov_matrix.shape[0]
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix)
|
|
59
|
+
|
|
60
|
+
# Sort by absolute value, descending
|
|
61
|
+
sort_idx = np.argsort(np.abs(eigenvalues))[::-1][:n_components]
|
|
62
|
+
eigenvalues_sorted = eigenvalues[sort_idx]
|
|
63
|
+
eigenvectors_sorted = eigenvectors[:, sort_idx]
|
|
64
|
+
return np.real(eigenvalues_sorted), np.real(eigenvectors_sorted)
|
|
65
|
+
except (ValueError, np.linalg.LinAlgError) as e:
|
|
66
|
+
if block_idx is not None:
|
|
67
|
+
_logger.warning(
|
|
68
|
+
f"PCA: Eigendecomposition failed for block {block_idx+1}, "
|
|
69
|
+
f"using identity matrix as fallback. Error: {type(e).__name__}"
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
_logger.warning(
|
|
73
|
+
f"PCA: Eigendecomposition failed, using identity matrix as fallback. Error: {type(e).__name__}"
|
|
74
|
+
)
|
|
75
|
+
eigenvectors = create_scaled_identity(n_series, DEFAULT_IDENTITY_SCALE)[:, :n_components]
|
|
76
|
+
eigenvalues = np.ones(n_components)
|
|
77
|
+
return eigenvalues, eigenvectors
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fit_pca(
|
|
81
|
+
X: Union[np.ndarray, "torch.Tensor"],
|
|
82
|
+
n_components: int,
|
|
83
|
+
cov_matrix: Optional[Union[np.ndarray, "torch.Tensor"]] = None,
|
|
84
|
+
block_idx: Optional[int] = None
|
|
85
|
+
) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray], Optional[np.ndarray]]:
|
|
86
|
+
"""Fit PCA by computing principal components.
|
|
87
|
+
|
|
88
|
+
If cov_matrix is provided, uses eigendecomposition.
|
|
89
|
+
If X is provided, uses NumPy's SVD for efficiency.
|
|
90
|
+
All computations are performed in NumPy.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
X : np.ndarray or torch.Tensor
|
|
95
|
+
Training data (T x N). If cov_matrix is provided, this is ignored.
|
|
96
|
+
n_components : int
|
|
97
|
+
Number of principal components to extract
|
|
98
|
+
cov_matrix : np.ndarray or torch.Tensor, optional
|
|
99
|
+
Precomputed covariance matrix (N x N). If None, computed from X.
|
|
100
|
+
block_idx : int, optional
|
|
101
|
+
Block index for error messages
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
Tuple[np.ndarray, np.ndarray, Optional[np.ndarray], Optional[np.ndarray]]
|
|
106
|
+
(eigenvalues, eigenvectors, mean, cov_matrix)
|
|
107
|
+
"""
|
|
108
|
+
if cov_matrix is not None:
|
|
109
|
+
# Use eigendecomposition for covariance matrix
|
|
110
|
+
eigenvalues, eigenvectors = compute_principal_components(
|
|
111
|
+
cov_matrix, n_components, block_idx=block_idx
|
|
112
|
+
)
|
|
113
|
+
# Store as NumPy array
|
|
114
|
+
cov_matrix_np = to_numpy(cov_matrix)
|
|
115
|
+
return eigenvalues, eigenvectors, None, cov_matrix_np
|
|
116
|
+
else:
|
|
117
|
+
# Convert to NumPy if needed
|
|
118
|
+
X = to_numpy(X)
|
|
119
|
+
|
|
120
|
+
# Center the data
|
|
121
|
+
mean_ = np.mean(X, axis=0, keepdims=True)
|
|
122
|
+
X_centered = X - mean_
|
|
123
|
+
|
|
124
|
+
# Use SVD for efficient low-rank PCA (NumPy equivalent of torch.pca_lowrank)
|
|
125
|
+
try:
|
|
126
|
+
# Check for NaN/Inf values before SVD
|
|
127
|
+
if np.any(~np.isfinite(X_centered)):
|
|
128
|
+
raise ValueError("Input contains non-finite values")
|
|
129
|
+
|
|
130
|
+
# Check for zero variance columns (can cause SVD issues)
|
|
131
|
+
col_stds = np.std(X_centered, axis=0)
|
|
132
|
+
if np.any(col_stds < 1e-10):
|
|
133
|
+
_logger.warning(
|
|
134
|
+
f"PCA: Some columns have near-zero variance (min_std={col_stds.min():.2e}). "
|
|
135
|
+
"This may cause SVD convergence issues. Consider removing constant columns."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Use truncated SVD for efficiency
|
|
139
|
+
U, S, Vt = np.linalg.svd(X_centered, full_matrices=False, hermitian=False)
|
|
140
|
+
|
|
141
|
+
# Validate SVD results
|
|
142
|
+
if not np.all(np.isfinite(S)):
|
|
143
|
+
raise ValueError("SVD produced non-finite singular values")
|
|
144
|
+
|
|
145
|
+
# Take top n_components
|
|
146
|
+
n_components_actual = min(n_components, len(S))
|
|
147
|
+
if n_components_actual < n_components:
|
|
148
|
+
_logger.warning(
|
|
149
|
+
f"PCA: Requested {n_components} components but only {len(S)} available. "
|
|
150
|
+
f"Using {n_components_actual} components."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
U = U[:, :n_components_actual]
|
|
154
|
+
S = S[:n_components_actual]
|
|
155
|
+
Vt = Vt[:n_components_actual, :]
|
|
156
|
+
|
|
157
|
+
# Vt contains the principal components (eigenvectors) as rows
|
|
158
|
+
# Transpose to get (N x n_components)
|
|
159
|
+
eigenvectors = Vt.T
|
|
160
|
+
# Convert singular values to eigenvalues
|
|
161
|
+
eigenvalues = S ** 2
|
|
162
|
+
|
|
163
|
+
return eigenvalues, eigenvectors, mean_, None
|
|
164
|
+
|
|
165
|
+
except (ValueError, np.linalg.LinAlgError) as e:
|
|
166
|
+
# Fallback: compute covariance and use eigendecomposition
|
|
167
|
+
error_msg = str(e) if str(e) else type(e).__name__
|
|
168
|
+
_logger.warning(
|
|
169
|
+
f"PCA SVD failed ({error_msg}), falling back to eigendecomposition. "
|
|
170
|
+
"This may be due to numerical instability or ill-conditioned data."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Clean data for covariance computation
|
|
174
|
+
X_centered_clean = np.nan_to_num(X_centered, nan=0.0, posinf=0.0, neginf=0.0)
|
|
175
|
+
T = X_centered_clean.shape[0]
|
|
176
|
+
|
|
177
|
+
# Compute covariance with regularization for stability
|
|
178
|
+
cov_raw = (X_centered_clean.T @ X_centered_clean) / max(T - 1, 1)
|
|
179
|
+
|
|
180
|
+
# Add small regularization to diagonal for numerical stability
|
|
181
|
+
reg = 1e-8 * np.eye(cov_raw.shape[0], dtype=cov_raw.dtype)
|
|
182
|
+
cov_matrix_np = cov_raw + reg
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
eigenvalues, eigenvectors = compute_principal_components(
|
|
186
|
+
cov_matrix_np, n_components, block_idx=block_idx
|
|
187
|
+
)
|
|
188
|
+
return eigenvalues, eigenvectors, mean_, cov_matrix_np
|
|
189
|
+
except (ValueError, np.linalg.LinAlgError) as e2:
|
|
190
|
+
# If eigendecomposition also fails, raise with helpful message
|
|
191
|
+
raise RuntimeError(
|
|
192
|
+
f"PCA failed: Both SVD and eigendecomposition failed. "
|
|
193
|
+
f"Original error: {error_msg}, Eigendecomposition error: {str(e2)}. "
|
|
194
|
+
"This may indicate severely ill-conditioned data. "
|
|
195
|
+
"Consider: (1) Removing constant/near-constant columns, "
|
|
196
|
+
"(2) Checking for outliers, (3) Using fewer components."
|
|
197
|
+
) from e2
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def encode_pca(
|
|
201
|
+
X: Union[np.ndarray, "torch.Tensor"],
|
|
202
|
+
eigenvectors: np.ndarray,
|
|
203
|
+
mean: Optional[np.ndarray] = None
|
|
204
|
+
) -> np.ndarray:
|
|
205
|
+
"""Extract factors using PCA eigenvectors.
|
|
206
|
+
|
|
207
|
+
All computations are performed in NumPy. Returns NumPy array.
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
X : np.ndarray or torch.Tensor
|
|
212
|
+
Observed data (T x N)
|
|
213
|
+
eigenvectors : np.ndarray
|
|
214
|
+
PCA eigenvectors (N x n_components) from fit_pca
|
|
215
|
+
mean : Optional[np.ndarray]
|
|
216
|
+
Mean vector (1 x N) from fit_pca. If None, centers using X's mean.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
factors : np.ndarray
|
|
221
|
+
Extracted factors (T x n_components)
|
|
222
|
+
"""
|
|
223
|
+
# Convert to NumPy if needed
|
|
224
|
+
X = to_numpy(X)
|
|
225
|
+
|
|
226
|
+
# Center the data
|
|
227
|
+
if mean is not None:
|
|
228
|
+
X_centered = X - mean
|
|
229
|
+
else:
|
|
230
|
+
X_centered = X - np.mean(X, axis=0, keepdims=True)
|
|
231
|
+
|
|
232
|
+
# Project: X @ eigenvectors
|
|
233
|
+
factors = X_centered @ eigenvectors
|
|
234
|
+
|
|
235
|
+
return factors
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Backward compatibility wrapper class (does not inherit from BaseEncoder)
|
|
239
|
+
class PCAEncoder:
|
|
240
|
+
"""Principal Component Analysis encoder for factor extraction (backward compatibility wrapper).
|
|
241
|
+
|
|
242
|
+
This is a wrapper around pure PCA functions for backward compatibility.
|
|
243
|
+
The actual implementation uses pure functions in this module (layer.pca).
|
|
244
|
+
|
|
245
|
+
Parameters
|
|
246
|
+
----------
|
|
247
|
+
n_components : int
|
|
248
|
+
Number of factors to extract
|
|
249
|
+
block_idx : int, optional
|
|
250
|
+
Block index for error messages
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def __init__(
|
|
254
|
+
self,
|
|
255
|
+
n_components: int,
|
|
256
|
+
block_idx: Optional[int] = None
|
|
257
|
+
):
|
|
258
|
+
self.n_components = n_components
|
|
259
|
+
self.block_idx = block_idx
|
|
260
|
+
|
|
261
|
+
# Will be set in fit()
|
|
262
|
+
self.eigenvectors: Optional[np.ndarray] = None
|
|
263
|
+
self.eigenvalues: Optional[np.ndarray] = None
|
|
264
|
+
self.cov_matrix: Optional[np.ndarray] = None
|
|
265
|
+
self.mean_: Optional[np.ndarray] = None
|
|
266
|
+
|
|
267
|
+
def fit(
|
|
268
|
+
self,
|
|
269
|
+
X: Union[np.ndarray, "torch.Tensor"],
|
|
270
|
+
cov_matrix: Optional[Union[np.ndarray, "torch.Tensor"]] = None,
|
|
271
|
+
**kwargs
|
|
272
|
+
) -> "PCAEncoder":
|
|
273
|
+
"""Fit PCA encoder by computing principal components."""
|
|
274
|
+
eigenvalues, eigenvectors, mean, cov_matrix_np = fit_pca(
|
|
275
|
+
X, self.n_components, cov_matrix=cov_matrix, block_idx=self.block_idx
|
|
276
|
+
)
|
|
277
|
+
self.eigenvalues = eigenvalues
|
|
278
|
+
self.eigenvectors = eigenvectors
|
|
279
|
+
self.mean_ = mean
|
|
280
|
+
self.cov_matrix = cov_matrix_np
|
|
281
|
+
return self
|
|
282
|
+
|
|
283
|
+
def encode(
|
|
284
|
+
self,
|
|
285
|
+
X: Union[np.ndarray, "torch.Tensor"],
|
|
286
|
+
**kwargs
|
|
287
|
+
) -> np.ndarray:
|
|
288
|
+
"""Extract factors using fitted PCA encoder."""
|
|
289
|
+
if self.eigenvectors is None:
|
|
290
|
+
from ..utils.errors import ModelNotTrainedError
|
|
291
|
+
raise ModelNotTrainedError(
|
|
292
|
+
"PCAEncoder must be fitted before encoding. Call fit() first.",
|
|
293
|
+
details="The encoder has not been fitted with training data yet."
|
|
294
|
+
)
|
|
295
|
+
return encode_pca(X, self.eigenvectors, mean=self.mean_)
|
|
296
|
+
|
|
297
|
+
def fit_encode(
|
|
298
|
+
self,
|
|
299
|
+
X: Union[np.ndarray, "torch.Tensor"],
|
|
300
|
+
**kwargs
|
|
301
|
+
) -> np.ndarray:
|
|
302
|
+
"""Fit encoder and extract factors in one step."""
|
|
303
|
+
self.fit(X, **kwargs)
|
|
304
|
+
return self.encode(X, **kwargs)
|
dfm_python/models/base.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
"""Abstract base class for factor models."""
|
|
1
|
+
"""Abstract base class for factor models and model components."""
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
4
|
from typing import Optional, Any, Union, Tuple
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
import numpy as np
|
|
7
|
+
import torch
|
|
8
|
+
import torch.nn as nn
|
|
7
9
|
|
|
8
10
|
from ..config import BaseResult
|
|
9
11
|
from ..utils.errors import ModelNotTrainedError
|
|
@@ -71,3 +73,51 @@ class BaseFactorModel(ABC):
|
|
|
71
73
|
def load(cls, path: Union[str, Path], *args, **kwargs) -> 'BaseFactorModel':
|
|
72
74
|
"""Load model from file."""
|
|
73
75
|
raise NotImplementedError("Subclasses must implement load()")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BaseEncoder(nn.Module, ABC):
|
|
79
|
+
"""Abstract base class for model encoders.
|
|
80
|
+
|
|
81
|
+
Model-specific encoder base class. Encoders extract latent factors
|
|
82
|
+
from observed data. This is a PyTorch module for neural encoders.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
87
|
+
"""Forward pass through encoder.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
x : torch.Tensor
|
|
92
|
+
Input data
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
torch.Tensor
|
|
97
|
+
Encoded representation (factors)
|
|
98
|
+
"""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class BaseDecoder(nn.Module, ABC):
|
|
103
|
+
"""Abstract base class for model decoders.
|
|
104
|
+
|
|
105
|
+
Model-specific decoder base class. Decoders map latent factors
|
|
106
|
+
to observed data. This is a PyTorch module for neural decoders.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def forward(self, f: torch.Tensor) -> torch.Tensor:
|
|
111
|
+
"""Forward pass through decoder.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
f : torch.Tensor
|
|
116
|
+
Latent factors
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
torch.Tensor
|
|
121
|
+
Reconstructed observations
|
|
122
|
+
"""
|
|
123
|
+
pass
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""DDFM model components."""
|
|
2
|
+
|
|
3
|
+
from .encoder import DDFMEncoder, SimpleAutoencoder, extract_decoder_params
|
|
4
|
+
from .decoder import DDFMLinearDecoder, DDFMMLPDecoder
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'DDFMEncoder',
|
|
8
|
+
'DDFMLinearDecoder',
|
|
9
|
+
'DDFMMLPDecoder',
|
|
10
|
+
'SimpleAutoencoder',
|
|
11
|
+
'extract_decoder_params',
|
|
12
|
+
]
|
dfm_python/models/ddfm/ddfm.py
CHANGED
|
@@ -21,7 +21,7 @@ from ...numeric.statistic import (
|
|
|
21
21
|
average_3d_array
|
|
22
22
|
)
|
|
23
23
|
from ...numeric.builder import build_ddfm_optimizer
|
|
24
|
-
from
|
|
24
|
+
from .encoder import SimpleAutoencoder
|
|
25
25
|
from .sampling import run_mcmc_iteration
|
|
26
26
|
from ...config.schema.params import DDFMModelState
|
|
27
27
|
from ...config.schema.results import DDFMResult
|