dfm-python 0.4.7__py3-none-any.whl → 0.4.9__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 -2
- dfm_python/logger/logger.py +22 -9
- dfm_python/models/__init__.py +2 -0
- dfm_python/models/base.py +461 -0
- dfm_python/models/ddfm.py +120 -974
- dfm_python/models/dfm.py +219 -23
- dfm_python/models/mcmc.py +518 -0
- dfm_python/models/utils.py +577 -0
- dfm_python/ssm/em.py +67 -14
- dfm_python/ssm/kalman.py +50 -1
- dfm_python/ssm/utils.py +45 -0
- dfm_python/utils/__init__.py +9 -46
- {dfm_python-0.4.7.dist-info → dfm_python-0.4.9.dist-info}/METADATA +1 -1
- {dfm_python-0.4.7.dist-info → dfm_python-0.4.9.dist-info}/RECORD +16 -14
- {dfm_python-0.4.7.dist-info → dfm_python-0.4.9.dist-info}/WHEEL +0 -0
- {dfm_python-0.4.7.dist-info → dfm_python-0.4.9.dist-info}/licenses/LICENSE +0 -0
dfm_python/__init__.py
CHANGED
|
@@ -65,7 +65,7 @@ Note: DFMConfig and SeriesConfig are internal implementation details.
|
|
|
65
65
|
For detailed documentation, see the README.md file and the tutorial notebooks/scripts.
|
|
66
66
|
"""
|
|
67
67
|
|
|
68
|
-
__version__ = "0.4.
|
|
68
|
+
__version__ = "0.4.9"
|
|
69
69
|
|
|
70
70
|
# ============================================================================
|
|
71
71
|
# PUBLIC API DEFINITION
|
|
@@ -80,7 +80,6 @@ __version__ = "0.4.7"
|
|
|
80
80
|
# 3. Core utilities: TimeIndex, diagnostics
|
|
81
81
|
# 4. Models: BaseFactorModel, DDFM (low-level)
|
|
82
82
|
# 5. Data & Results: DFMResult
|
|
83
|
-
# 6. Data & Results: DFMResult
|
|
84
83
|
# ============================================================================
|
|
85
84
|
|
|
86
85
|
# Configuration (from config/ subpackage)
|
dfm_python/logger/logger.py
CHANGED
|
@@ -37,20 +37,29 @@ def get_logger(name: str) -> logging.Logger:
|
|
|
37
37
|
# Configure package logger if not already configured
|
|
38
38
|
# Use a flag to avoid re-configuring if already done
|
|
39
39
|
if not hasattr(package_logger, '_dfm_configured'):
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
# CRITICAL: Check if handlers already exist to prevent duplicates
|
|
41
|
+
# This can happen if configure_logging() was called before get_logger()
|
|
42
|
+
if not package_logger.handlers:
|
|
43
|
+
# Configure handler for dfm_python package logger
|
|
44
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
45
|
+
handler.setFormatter(
|
|
46
|
+
logging.Formatter(
|
|
47
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
48
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
|
49
|
+
)
|
|
46
50
|
)
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
package_logger.addHandler(handler)
|
|
52
|
+
|
|
49
53
|
package_logger.setLevel(logging.INFO)
|
|
54
|
+
# CRITICAL: Disable propagation to root logger to prevent duplicate messages
|
|
55
|
+
# Root logger may have handlers from other code (e.g., basicConfig)
|
|
56
|
+
# By setting propagate=False, we ensure messages only go through our handler
|
|
57
|
+
package_logger.propagate = False
|
|
50
58
|
# Mark as configured to avoid duplicate handlers
|
|
51
59
|
package_logger._dfm_configured = True
|
|
52
60
|
|
|
53
|
-
#
|
|
61
|
+
# Child logger should propagate to package logger (not root)
|
|
62
|
+
# Since package logger has propagate=False, messages won't go to root
|
|
54
63
|
logger.propagate = True
|
|
55
64
|
|
|
56
65
|
return logger
|
|
@@ -110,6 +119,10 @@ def configure_logging(
|
|
|
110
119
|
if hasattr(logger, '_dfm_configured'):
|
|
111
120
|
delattr(logger, '_dfm_configured')
|
|
112
121
|
|
|
122
|
+
# CRITICAL: Disable propagation to root logger to prevent duplicate messages
|
|
123
|
+
# If root logger also has handlers, messages would be logged twice
|
|
124
|
+
logger.propagate = False
|
|
125
|
+
|
|
113
126
|
# Add console handler
|
|
114
127
|
console_handler = logging.StreamHandler(sys.stdout)
|
|
115
128
|
console_handler.setLevel(level)
|
dfm_python/models/__init__.py
CHANGED
|
@@ -17,8 +17,10 @@ __all__ = [
|
|
|
17
17
|
|
|
18
18
|
# DDFM implementation
|
|
19
19
|
from .ddfm import DDFM, DDFMModel
|
|
20
|
+
from .mcmc import DDFMMCMCTrainer
|
|
20
21
|
__all__.extend([
|
|
21
22
|
'DDFM', # High-level API
|
|
22
23
|
'DDFMModel', # Low-level implementation
|
|
24
|
+
'DDFMMCMCTrainer', # MCMC training procedure
|
|
23
25
|
])
|
|
24
26
|
|
dfm_python/models/base.py
CHANGED
|
@@ -11,6 +11,7 @@ from abc import abstractmethod
|
|
|
11
11
|
from typing import Optional, Union, Tuple, Any, Dict, TYPE_CHECKING
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
import numpy as np
|
|
14
|
+
import torch
|
|
14
15
|
import pytorch_lightning as pl
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
@@ -349,6 +350,394 @@ class BaseFactorModel(pl.LightningModule):
|
|
|
349
350
|
|
|
350
351
|
return X_torch, Mx, Wx
|
|
351
352
|
|
|
353
|
+
def _compute_default_horizon(self) -> int:
|
|
354
|
+
"""Compute default horizon based on clock frequency.
|
|
355
|
+
|
|
356
|
+
Returns
|
|
357
|
+
-------
|
|
358
|
+
int
|
|
359
|
+
Default horizon (1 year of periods based on clock frequency)
|
|
360
|
+
"""
|
|
361
|
+
from ..config.utils import get_periods_per_year
|
|
362
|
+
from ..utils.helpers import get_clock_frequency
|
|
363
|
+
clock = get_clock_frequency(self.config, 'm')
|
|
364
|
+
return get_periods_per_year(clock)
|
|
365
|
+
|
|
366
|
+
def _validate_horizon(self, horizon: int) -> None:
|
|
367
|
+
"""Validate horizon parameter.
|
|
368
|
+
|
|
369
|
+
Parameters
|
|
370
|
+
----------
|
|
371
|
+
horizon : int
|
|
372
|
+
Horizon value to validate
|
|
373
|
+
|
|
374
|
+
Raises
|
|
375
|
+
------
|
|
376
|
+
ValueError
|
|
377
|
+
If horizon is not a positive integer
|
|
378
|
+
"""
|
|
379
|
+
if horizon <= 0:
|
|
380
|
+
raise ValueError(
|
|
381
|
+
self._format_error_message(
|
|
382
|
+
operation="prediction",
|
|
383
|
+
reason=f"horizon must be a positive integer, got {horizon}",
|
|
384
|
+
guidance="Please provide a positive integer value for the forecast horizon"
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def _forecast_var_factors(
|
|
389
|
+
self,
|
|
390
|
+
Z_last: np.ndarray,
|
|
391
|
+
A: np.ndarray,
|
|
392
|
+
p: int,
|
|
393
|
+
horizon: int,
|
|
394
|
+
Z_prev: Optional[np.ndarray] = None
|
|
395
|
+
) -> np.ndarray:
|
|
396
|
+
"""Forecast factors using VAR dynamics.
|
|
397
|
+
|
|
398
|
+
Supports VAR(1) and VAR(2) factor dynamics.
|
|
399
|
+
|
|
400
|
+
Parameters
|
|
401
|
+
----------
|
|
402
|
+
Z_last : np.ndarray
|
|
403
|
+
Last factor state (m,)
|
|
404
|
+
A : np.ndarray
|
|
405
|
+
Transition matrix. For VAR(1): (m x m), for VAR(2): (m x 2m)
|
|
406
|
+
p : int
|
|
407
|
+
VAR order (1 or 2)
|
|
408
|
+
horizon : int
|
|
409
|
+
Number of periods to forecast
|
|
410
|
+
Z_prev : np.ndarray, optional
|
|
411
|
+
Previous factor state for VAR(2) (m,). Required if p == 2.
|
|
412
|
+
|
|
413
|
+
Returns
|
|
414
|
+
-------
|
|
415
|
+
np.ndarray
|
|
416
|
+
Forecasted factors (horizon x m)
|
|
417
|
+
"""
|
|
418
|
+
if p == 1:
|
|
419
|
+
# VAR(1): f_t = A @ f_{t-1}
|
|
420
|
+
Z_forecast = np.zeros((horizon, Z_last.shape[0]))
|
|
421
|
+
Z_forecast[0, :] = A @ Z_last
|
|
422
|
+
for h in range(1, horizon):
|
|
423
|
+
Z_forecast[h, :] = A @ Z_forecast[h - 1, :]
|
|
424
|
+
elif p == 2:
|
|
425
|
+
# VAR(2): f_t = A1 @ f_{t-1} + A2 @ f_{t-2}
|
|
426
|
+
if Z_prev is None:
|
|
427
|
+
# Fallback to VAR(1) if not enough history
|
|
428
|
+
A1 = A[:, :Z_last.shape[0]]
|
|
429
|
+
Z_forecast = np.zeros((horizon, Z_last.shape[0]))
|
|
430
|
+
Z_forecast[0, :] = A1 @ Z_last
|
|
431
|
+
for h in range(1, horizon):
|
|
432
|
+
Z_forecast[h, :] = A1 @ Z_forecast[h - 1, :]
|
|
433
|
+
else:
|
|
434
|
+
A1 = A[:, :Z_last.shape[0]]
|
|
435
|
+
A2 = A[:, Z_last.shape[0]:]
|
|
436
|
+
Z_forecast = np.zeros((horizon, Z_last.shape[0]))
|
|
437
|
+
Z_forecast[0, :] = A1 @ Z_last + A2 @ Z_prev
|
|
438
|
+
if horizon > 1:
|
|
439
|
+
Z_forecast[1, :] = A1 @ Z_forecast[0, :] + A2 @ Z_last
|
|
440
|
+
for h in range(2, horizon):
|
|
441
|
+
Z_forecast[h, :] = A1 @ Z_forecast[h - 1, :] + A2 @ Z_forecast[h - 2, :]
|
|
442
|
+
else:
|
|
443
|
+
raise ValueError(
|
|
444
|
+
self._format_error_message(
|
|
445
|
+
operation="prediction",
|
|
446
|
+
reason=f"unsupported VAR order {p}",
|
|
447
|
+
guidance="Only VAR(1) and VAR(2) are supported. Please use factor_order=1 or factor_order=2"
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
return Z_forecast
|
|
451
|
+
|
|
452
|
+
def _transform_factors_to_observations(
|
|
453
|
+
self,
|
|
454
|
+
Z_forecast: np.ndarray,
|
|
455
|
+
C: np.ndarray,
|
|
456
|
+
Wx: np.ndarray,
|
|
457
|
+
Mx: np.ndarray
|
|
458
|
+
) -> np.ndarray:
|
|
459
|
+
"""Transform forecasted factors to observed series.
|
|
460
|
+
|
|
461
|
+
Parameters
|
|
462
|
+
----------
|
|
463
|
+
Z_forecast : np.ndarray
|
|
464
|
+
Forecasted factors (horizon x m)
|
|
465
|
+
C : np.ndarray
|
|
466
|
+
Loading matrix (N x m)
|
|
467
|
+
Wx : np.ndarray
|
|
468
|
+
Standard deviation values for unstandardization (N,)
|
|
469
|
+
Mx : np.ndarray
|
|
470
|
+
Mean values for unstandardization (N,)
|
|
471
|
+
|
|
472
|
+
Returns
|
|
473
|
+
-------
|
|
474
|
+
np.ndarray
|
|
475
|
+
Forecasted observations (horizon x N)
|
|
476
|
+
"""
|
|
477
|
+
X_forecast_std = Z_forecast @ C.T
|
|
478
|
+
X_forecast = X_forecast_std * Wx + Mx
|
|
479
|
+
return X_forecast
|
|
480
|
+
|
|
481
|
+
def _update_factor_state_with_history(
|
|
482
|
+
self,
|
|
483
|
+
history: int,
|
|
484
|
+
result: 'BaseResult',
|
|
485
|
+
kalman_filter: Optional[Any] = None
|
|
486
|
+
) -> Optional[np.ndarray]:
|
|
487
|
+
"""Update factor state using recent N periods of data.
|
|
488
|
+
|
|
489
|
+
This method re-runs the Kalman filter (for DFM) or extracts factors
|
|
490
|
+
via encoder and runs Kalman filter (for DDFM) using only the most
|
|
491
|
+
recent N periods of data for efficiency.
|
|
492
|
+
|
|
493
|
+
Parameters
|
|
494
|
+
----------
|
|
495
|
+
history : int
|
|
496
|
+
Number of recent periods to use for state update
|
|
497
|
+
result : BaseResult
|
|
498
|
+
Model result containing parameters (A, C, Q, R, Z_0, V_0)
|
|
499
|
+
kalman_filter : Any, optional
|
|
500
|
+
Kalman filter instance. If None, creates a new one.
|
|
501
|
+
|
|
502
|
+
Returns
|
|
503
|
+
-------
|
|
504
|
+
np.ndarray or None
|
|
505
|
+
Updated last factor state (m,), or None if update failed
|
|
506
|
+
"""
|
|
507
|
+
# Get data from DataModule
|
|
508
|
+
if not hasattr(self, '_data_module') or self._data_module is None:
|
|
509
|
+
# Try to get from trainer
|
|
510
|
+
if hasattr(self, 'trainer') and self.trainer is not None:
|
|
511
|
+
if hasattr(self.trainer, 'datamodule') and self.trainer.datamodule is not None:
|
|
512
|
+
self._data_module = self.trainer.datamodule
|
|
513
|
+
|
|
514
|
+
if not hasattr(self, '_data_module') or self._data_module is None:
|
|
515
|
+
_logger.warning(
|
|
516
|
+
f"{self.__class__.__name__} predict(): DataModule not available for history={history}, "
|
|
517
|
+
f"using training state instead"
|
|
518
|
+
)
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
# Get processed data (T x N)
|
|
523
|
+
X_torch = self._data_module.get_processed_data()
|
|
524
|
+
|
|
525
|
+
# Convert to numpy if needed
|
|
526
|
+
if isinstance(X_torch, torch.Tensor):
|
|
527
|
+
X_data = X_torch.cpu().numpy()
|
|
528
|
+
else:
|
|
529
|
+
X_data = np.asarray(X_torch)
|
|
530
|
+
|
|
531
|
+
# Slice to recent N periods
|
|
532
|
+
if X_data.shape[0] > history:
|
|
533
|
+
X_recent = X_data[-history:, :]
|
|
534
|
+
_logger.debug(
|
|
535
|
+
f"{self.__class__.__name__} predict(): Using recent {history} periods "
|
|
536
|
+
f"(out of {X_data.shape[0]}) for state update"
|
|
537
|
+
)
|
|
538
|
+
else:
|
|
539
|
+
X_recent = X_data
|
|
540
|
+
_logger.debug(
|
|
541
|
+
f"{self.__class__.__name__} predict(): Using all {X_data.shape[0]} periods "
|
|
542
|
+
f"(less than history={history}) for state update"
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# Standardize data using result's Mx and Wx
|
|
546
|
+
Mx = result.Mx
|
|
547
|
+
Wx = result.Wx
|
|
548
|
+
if Mx is not None and Wx is not None:
|
|
549
|
+
# Check if data is already standardized (mean ~0, std ~1)
|
|
550
|
+
if np.abs(np.nanmean(X_recent)) > 0.1 or np.nanstd(X_recent) > 2.0:
|
|
551
|
+
# Data not standardized, standardize it
|
|
552
|
+
X_recent_std = (X_recent - Mx) / np.where(Wx != 0, Wx, 1.0)
|
|
553
|
+
else:
|
|
554
|
+
# Data already standardized
|
|
555
|
+
X_recent_std = X_recent
|
|
556
|
+
else:
|
|
557
|
+
X_recent_std = X_recent
|
|
558
|
+
|
|
559
|
+
# Handle NaN values (missing data)
|
|
560
|
+
X_recent_std = np.where(np.isfinite(X_recent_std), X_recent_std, np.nan)
|
|
561
|
+
|
|
562
|
+
# Model-specific state update
|
|
563
|
+
# Use __class__.__name__ to avoid circular imports
|
|
564
|
+
# Check if model has encoder attribute (DDFM-specific)
|
|
565
|
+
if hasattr(self, 'encoder') and self.encoder is not None:
|
|
566
|
+
# DDFM: Extract factors via encoder, then use Kalman filter
|
|
567
|
+
return self._update_factor_state_ddfm(
|
|
568
|
+
X_recent_std, result, kalman_filter
|
|
569
|
+
)
|
|
570
|
+
else:
|
|
571
|
+
# DFM: Use Kalman filter directly on standardized data
|
|
572
|
+
return self._update_factor_state_dfm(
|
|
573
|
+
X_recent_std, result, kalman_filter
|
|
574
|
+
)
|
|
575
|
+
except Exception as e:
|
|
576
|
+
_logger.warning(
|
|
577
|
+
f"{self.__class__.__name__} predict(): Failed to update factor state with history={history}, "
|
|
578
|
+
f"using training state instead. Error: {type(e).__name__}: {str(e)}"
|
|
579
|
+
)
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
def _update_factor_state_dfm(
|
|
583
|
+
self,
|
|
584
|
+
X_recent_std: np.ndarray,
|
|
585
|
+
result: 'BaseResult',
|
|
586
|
+
kalman_filter: Optional[Any] = None
|
|
587
|
+
) -> np.ndarray:
|
|
588
|
+
"""Update factor state for DFM using Kalman filter.
|
|
589
|
+
|
|
590
|
+
Parameters
|
|
591
|
+
----------
|
|
592
|
+
X_recent_std : np.ndarray
|
|
593
|
+
Standardized recent data (T x N)
|
|
594
|
+
result : BaseResult
|
|
595
|
+
Model result containing parameters
|
|
596
|
+
kalman_filter : Any, optional
|
|
597
|
+
Kalman filter instance
|
|
598
|
+
|
|
599
|
+
Returns
|
|
600
|
+
-------
|
|
601
|
+
np.ndarray
|
|
602
|
+
Updated last factor state (m,)
|
|
603
|
+
"""
|
|
604
|
+
import torch
|
|
605
|
+
|
|
606
|
+
# Convert to torch tensor: (N x T) format for Kalman filter
|
|
607
|
+
Y = torch.tensor(X_recent_std.T, dtype=torch.float32) # (N x T)
|
|
608
|
+
|
|
609
|
+
# Extract parameters
|
|
610
|
+
A = result.A
|
|
611
|
+
C = result.C
|
|
612
|
+
Q = result.Q
|
|
613
|
+
R = result.R
|
|
614
|
+
Z_0 = result.Z_0
|
|
615
|
+
V_0 = result.V_0
|
|
616
|
+
|
|
617
|
+
# Convert parameters to torch
|
|
618
|
+
A_torch = torch.tensor(A, dtype=torch.float32)
|
|
619
|
+
C_torch = torch.tensor(C, dtype=torch.float32)
|
|
620
|
+
Q_torch = torch.tensor(Q, dtype=torch.float32)
|
|
621
|
+
R_torch = torch.tensor(R, dtype=torch.float32)
|
|
622
|
+
Z_0_torch = torch.tensor(Z_0, dtype=torch.float32)
|
|
623
|
+
V_0_torch = torch.tensor(V_0, dtype=torch.float32)
|
|
624
|
+
|
|
625
|
+
# Re-run Kalman filter with recent data
|
|
626
|
+
if kalman_filter is None:
|
|
627
|
+
if hasattr(self, 'kalman') and self.kalman is not None:
|
|
628
|
+
kalman_filter = self.kalman
|
|
629
|
+
else:
|
|
630
|
+
from ..ssm.kalman import KalmanFilter
|
|
631
|
+
kalman_filter = KalmanFilter(
|
|
632
|
+
min_eigenval=1e-8,
|
|
633
|
+
inv_regularization=1e-6,
|
|
634
|
+
cholesky_regularization=1e-8
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# Run Kalman smoother
|
|
638
|
+
zsmooth, Vsmooth, _, _ = kalman_filter(
|
|
639
|
+
Y, A_torch, C_torch, Q_torch, R_torch, Z_0_torch, V_0_torch
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
# zsmooth is (m x (T+1)), transpose to ((T+1) x m)
|
|
643
|
+
Zsmooth = zsmooth.T # ((T+1) x m)
|
|
644
|
+
|
|
645
|
+
# Get last factor state (skip initial state at index 0)
|
|
646
|
+
Z_last = Zsmooth[-1, :].cpu().numpy() # (m,)
|
|
647
|
+
|
|
648
|
+
return Z_last
|
|
649
|
+
|
|
650
|
+
def _update_factor_state_ddfm(
|
|
651
|
+
self,
|
|
652
|
+
X_recent_std: np.ndarray,
|
|
653
|
+
result: 'BaseResult',
|
|
654
|
+
kalman_filter: Optional[Any] = None
|
|
655
|
+
) -> np.ndarray:
|
|
656
|
+
"""Update factor state for DDFM using encoder and Kalman filter.
|
|
657
|
+
|
|
658
|
+
For DDFM, we first extract factors using the encoder, then run
|
|
659
|
+
Kalman filter on those factors to get smoothed state.
|
|
660
|
+
|
|
661
|
+
Parameters
|
|
662
|
+
----------
|
|
663
|
+
X_recent_std : np.ndarray
|
|
664
|
+
Standardized recent data (T x N)
|
|
665
|
+
result : BaseResult
|
|
666
|
+
Model result containing parameters
|
|
667
|
+
kalman_filter : Any, optional
|
|
668
|
+
Kalman filter instance
|
|
669
|
+
|
|
670
|
+
Returns
|
|
671
|
+
-------
|
|
672
|
+
np.ndarray
|
|
673
|
+
Updated last factor state (m,)
|
|
674
|
+
"""
|
|
675
|
+
import torch
|
|
676
|
+
|
|
677
|
+
# Extract factors using encoder
|
|
678
|
+
# Note: encoder is a DDFM-specific attribute, accessed via model instance
|
|
679
|
+
if not hasattr(self, 'encoder') or self.encoder is None:
|
|
680
|
+
_logger.warning(
|
|
681
|
+
f"{self.__class__.__name__} predict(): Encoder not available for history update, "
|
|
682
|
+
"using training state instead"
|
|
683
|
+
)
|
|
684
|
+
return None
|
|
685
|
+
|
|
686
|
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
|
687
|
+
self.encoder.eval()
|
|
688
|
+
|
|
689
|
+
# Convert to torch and extract factors
|
|
690
|
+
X_tensor = torch.tensor(X_recent_std, device=device, dtype=torch.float32)
|
|
691
|
+
with torch.no_grad():
|
|
692
|
+
factors_raw = self.encoder(X_tensor).cpu().numpy() # (T x m)
|
|
693
|
+
|
|
694
|
+
# Extract parameters for Kalman filter
|
|
695
|
+
A = result.A
|
|
696
|
+
Q = result.Q
|
|
697
|
+
Z_0 = result.Z_0
|
|
698
|
+
V_0 = result.V_0
|
|
699
|
+
p = result.p # VAR order
|
|
700
|
+
|
|
701
|
+
# For DDFM, the measurement equation is identity (factors are directly observed)
|
|
702
|
+
# So C = I (identity matrix), R = small noise
|
|
703
|
+
m = factors_raw.shape[1]
|
|
704
|
+
C = np.eye(m) # Identity matrix (factors are directly observed)
|
|
705
|
+
R = np.eye(m) * 1e-8 # Small noise for numerical stability
|
|
706
|
+
|
|
707
|
+
# Convert to torch tensor: (m x T) format for Kalman filter
|
|
708
|
+
# Note: For DDFM, we filter factors directly, so Y = factors.T
|
|
709
|
+
Y = torch.tensor(factors_raw.T, dtype=torch.float32) # (m x T)
|
|
710
|
+
|
|
711
|
+
# Convert parameters to torch
|
|
712
|
+
A_torch = torch.tensor(A, dtype=torch.float32)
|
|
713
|
+
C_torch = torch.tensor(C, dtype=torch.float32)
|
|
714
|
+
Q_torch = torch.tensor(Q, dtype=torch.float32)
|
|
715
|
+
R_torch = torch.tensor(R, dtype=torch.float32)
|
|
716
|
+
Z_0_torch = torch.tensor(Z_0, dtype=torch.float32)
|
|
717
|
+
V_0_torch = torch.tensor(V_0, dtype=torch.float32)
|
|
718
|
+
|
|
719
|
+
# Create or use Kalman filter
|
|
720
|
+
if kalman_filter is None:
|
|
721
|
+
from ..ssm.kalman import KalmanFilter
|
|
722
|
+
kalman_filter = KalmanFilter(
|
|
723
|
+
min_eigenval=1e-8,
|
|
724
|
+
inv_regularization=1e-6,
|
|
725
|
+
cholesky_regularization=1e-8
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
# Run Kalman smoother on factors
|
|
729
|
+
zsmooth, Vsmooth, _, _ = kalman_filter(
|
|
730
|
+
Y, A_torch, C_torch, Q_torch, R_torch, Z_0_torch, V_0_torch
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
# zsmooth is (m x (T+1)), transpose to ((T+1) x m)
|
|
734
|
+
Zsmooth = zsmooth.T # ((T+1) x m)
|
|
735
|
+
|
|
736
|
+
# Get last factor state (skip initial state at index 0)
|
|
737
|
+
Z_last = Zsmooth[-1, :].cpu().numpy() # (m,)
|
|
738
|
+
|
|
739
|
+
return Z_last
|
|
740
|
+
|
|
352
741
|
@property
|
|
353
742
|
def result(self) -> BaseResult:
|
|
354
743
|
"""Get last fit result.
|
|
@@ -366,6 +755,7 @@ class BaseFactorModel(pl.LightningModule):
|
|
|
366
755
|
self,
|
|
367
756
|
horizon: Optional[int] = None,
|
|
368
757
|
*,
|
|
758
|
+
history: Optional[int] = None,
|
|
369
759
|
return_series: bool = True,
|
|
370
760
|
return_factors: bool = True
|
|
371
761
|
) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]:
|
|
@@ -375,6 +765,11 @@ class BaseFactorModel(pl.LightningModule):
|
|
|
375
765
|
----------
|
|
376
766
|
horizon : int, optional
|
|
377
767
|
Number of periods ahead to forecast. If None, uses default based on clock frequency.
|
|
768
|
+
history : int, optional
|
|
769
|
+
Number of historical periods to use for Kalman filter update before prediction.
|
|
770
|
+
If None, uses full history (default). If specified (e.g., 60), uses only the most
|
|
771
|
+
recent N periods for efficiency. Initial state (Z_0, V_0) is always estimated from
|
|
772
|
+
full history (including any new data beyond training period).
|
|
378
773
|
return_series : bool, default True
|
|
379
774
|
Whether to return forecasted series.
|
|
380
775
|
return_factors : bool, default True
|
|
@@ -387,6 +782,13 @@ class BaseFactorModel(pl.LightningModule):
|
|
|
387
782
|
- If both return_series and return_factors are True: (X_forecast, Z_forecast)
|
|
388
783
|
- If only return_series is True: X_forecast
|
|
389
784
|
- If only return_factors is True: Z_forecast
|
|
785
|
+
|
|
786
|
+
Notes
|
|
787
|
+
-----
|
|
788
|
+
When history is specified, the method uses only the most recent N periods for
|
|
789
|
+
Kalman filter update, improving computational efficiency. The initial state
|
|
790
|
+
(Z_0, V_0) is always estimated from full history (including any new data beyond
|
|
791
|
+
training period), ensuring accuracy while maintaining efficiency.
|
|
390
792
|
"""
|
|
391
793
|
pass
|
|
392
794
|
|
|
@@ -403,6 +805,65 @@ class BaseFactorModel(pl.LightningModule):
|
|
|
403
805
|
"""
|
|
404
806
|
raise NotImplementedError("Subclasses must implement get_result()")
|
|
405
807
|
|
|
808
|
+
@property
|
|
809
|
+
def nowcast(self) -> 'Nowcast':
|
|
810
|
+
"""Get nowcast manager for generating nowcasts.
|
|
811
|
+
|
|
812
|
+
Returns
|
|
813
|
+
-------
|
|
814
|
+
Nowcast
|
|
815
|
+
Nowcast manager object that provides methods for:
|
|
816
|
+
- Generating nowcasts (forecasts for current period)
|
|
817
|
+
- Computing revision impacts
|
|
818
|
+
- Analyzing data releases
|
|
819
|
+
- News decomposition
|
|
820
|
+
|
|
821
|
+
Raises
|
|
822
|
+
------
|
|
823
|
+
ValueError
|
|
824
|
+
If model has not been trained yet. Call trainer.fit(model, data_module) first.
|
|
825
|
+
|
|
826
|
+
Examples
|
|
827
|
+
--------
|
|
828
|
+
>>> model = DFM()
|
|
829
|
+
>>> model.load_config('config.yaml')
|
|
830
|
+
>>> trainer.fit(model, data_module)
|
|
831
|
+
>>> nowcast = model.nowcast
|
|
832
|
+
>>> value = nowcast('gdp', view_date='2024-01-15', target_period='2024Q1')
|
|
833
|
+
"""
|
|
834
|
+
self._check_trained()
|
|
835
|
+
|
|
836
|
+
# Import here to avoid circular dependency
|
|
837
|
+
try:
|
|
838
|
+
from ..nowcast import Nowcast
|
|
839
|
+
except ImportError:
|
|
840
|
+
# Fallback: try importing from src.nowcasting if dfm_python.nowcast doesn't exist
|
|
841
|
+
try:
|
|
842
|
+
import sys
|
|
843
|
+
from pathlib import Path
|
|
844
|
+
# Add src to path if not already there
|
|
845
|
+
src_path = Path(__file__).parent.parent.parent.parent / 'src'
|
|
846
|
+
if str(src_path) not in sys.path:
|
|
847
|
+
sys.path.insert(0, str(src_path))
|
|
848
|
+
from nowcasting import Nowcast
|
|
849
|
+
except ImportError:
|
|
850
|
+
error_msg = self._format_error_message(
|
|
851
|
+
operation="nowcast access",
|
|
852
|
+
reason="Nowcast class not available",
|
|
853
|
+
guidance="Please ensure nowcasting module is available"
|
|
854
|
+
)
|
|
855
|
+
raise ValueError(error_msg)
|
|
856
|
+
|
|
857
|
+
# Get DataModule from model
|
|
858
|
+
data_module = getattr(self, '_data_module', None)
|
|
859
|
+
if data_module is None:
|
|
860
|
+
# Try to get from trainer if available
|
|
861
|
+
if hasattr(self, 'trainer') and self.trainer is not None:
|
|
862
|
+
if hasattr(self.trainer, 'datamodule') and self.trainer.datamodule is not None:
|
|
863
|
+
data_module = self.trainer.datamodule
|
|
864
|
+
|
|
865
|
+
return Nowcast(self, data_module=data_module)
|
|
866
|
+
|
|
406
867
|
def _load_config_common(
|
|
407
868
|
self,
|
|
408
869
|
source: Optional[Union[str, Path, Dict[str, Any], DFMConfig, ConfigSource]] = None,
|