py-ssa-lib 0.0.1__tar.gz

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.
@@ -0,0 +1,2 @@
1
+ graft src
2
+ recursive-exclude __pycache__ *.py[cod]
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.1
2
+ Name: py_ssa_lib
3
+ Version: 0.0.1
4
+ Summary: "This package implements MSSA and SSA in Python"
5
+ Home-page: https://github.com/K-Ibadullaev/py_ssa
6
+ Author: [
7
+ { Konstantin Ibadullaev, email= <konstantin.ibadullaev.post@gmail.com>}]
8
+ License: GPL-3.0-or-later
9
+ Requires-Python: >=3.10.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # PY-SSA PACKAGE
13
+ This package contains python implementations of the **Singular Spectrum Analysis(SSA)** and **Multichannel Singular Spectrum Analysis(MSSA)**. One can use them for the time series analysis and forecasting.
14
+
15
+ ## Installation
16
+ ```shell
17
+ $ python -m pip install py_ssa
18
+ ```
@@ -0,0 +1,7 @@
1
+ # PY-SSA PACKAGE
2
+ This package contains python implementations of the **Singular Spectrum Analysis(SSA)** and **Multichannel Singular Spectrum Analysis(MSSA)**. One can use them for the time series analysis and forecasting.
3
+
4
+ ## Installation
5
+ ```shell
6
+ $ python -m pip install py_ssa
7
+ ```
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,47 @@
1
+ [metadata]
2
+ name = py_ssa_lib
3
+ version = 0.0.1
4
+ url = https://github.com/K-Ibadullaev/py_ssa
5
+ author = [
6
+ { Konstantin Ibadullaev, email= <konstantin.ibadullaev.post@gmail.com>}]
7
+ description = "This package implements MSSA and SSA in Python"
8
+ long_description = file: README.md
9
+ long_description_content_type = text/markdown
10
+ license = GPL-3.0-or-later
11
+ license_files = LICENSE
12
+
13
+ [options]
14
+ package_dir =
15
+ =src
16
+ packages = find:
17
+ include_package_data = True
18
+ python_requires = >=3.10.0
19
+ install_requires =
20
+ numpy> = 2.0.0;
21
+ matplotlib> = 3.9.1;
22
+ scipy> = 1.14.0;
23
+ scikit-learn> = 1.5.1;
24
+ pandas> = 2.2.2;
25
+
26
+ [options.packages.find]
27
+ where = src
28
+ exclude =
29
+ test*
30
+
31
+ [tox:tox]
32
+ envlist = py310
33
+ isolated_build = True
34
+
35
+ [testenv]
36
+ deps =
37
+
38
+ pytest
39
+ pytest-cov
40
+ commands =
41
+ python -m pip install numpy matplotlib scipy scikit-learn pandas
42
+ pytest
43
+
44
+ [egg_info]
45
+ tag_build =
46
+ tag_date = 0
47
+
@@ -0,0 +1,438 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ import matplotlib.pyplot as plt
4
+ import scipy as sp
5
+ from matplotlib.ticker import MaxNLocator
6
+ from sklearn.utils.extmath import randomized_svd
7
+
8
+ class MSSA():
9
+ """
10
+ Creates an instance of the MSSA object
11
+
12
+ Parameters
13
+ ----------
14
+ Verbose: bool, outputs parameters used for an instance
15
+
16
+ Returns
17
+ -------
18
+ object of class MSSA
19
+ """
20
+
21
+ def __init__(self, Verbose=False):
22
+ self.Verbose = Verbose
23
+ if self.Verbose==True:
24
+ print("MSSA is initialized")
25
+
26
+ def construct_trajectory_matrix(self):
27
+ """
28
+ Constructs a trajectory matrix from a lagged versions of the time series
29
+
30
+ Parameters
31
+ ----------
32
+ Takes arguments from the instance
33
+
34
+ Returns
35
+ -------
36
+ X:numpy.array, representing the trajectory matrix
37
+ """
38
+
39
+ X = np.column_stack([np.column_stack([self.X_s[i:i+self.L,s] for i in range(0,self.K)]) for s in range(self.S)])
40
+ return X
41
+
42
+ def decompose_trajectory_matrix(self,**kwargs):
43
+ """
44
+ Decomposes a trajectory matrix using full svd or randomized svd
45
+
46
+ Parameters
47
+ ----------
48
+ Takes arguments from the instance
49
+ **kwargs for the svd routine
50
+
51
+ Returns
52
+ -------
53
+ U,Sigma, V.T: all are numpy.arrays, representing the left-hand side and right-hand side eigenvectors and
54
+ corresponding eigenvalues
55
+ """
56
+ if self.decomposition == "svd":
57
+ if self.Verbose==True:
58
+ print("Using full SVD")
59
+ self.d = np.linalg.matrix_rank(self.X_ss)
60
+ U, Sigma, V = np.linalg.svd(self.X_ss,**kwargs)
61
+ elif self.decomposition == "rand_svd" :
62
+ if self.Verbose==True:
63
+ print("Using randomized SVD ")
64
+ self.d = self.L // 2 - 1
65
+
66
+ U, Sigma, V = randomized_svd(self.X_ss,n_components= self.d,#self.L - self.L//3 ,
67
+ n_oversamples=100, random_state=0,power_iteration_normalizer='LU',n_iter=15)
68
+ #self.d = np.max(np.where(Sigma>0))
69
+
70
+ else:
71
+ self.d = 1
72
+ raise ValueError("Wrong decomposition type")
73
+ return U, Sigma, V.T
74
+
75
+ def elementary_matrix(self):
76
+ """
77
+ Constructs a matrix(tensor) of all elementary components
78
+
79
+ Parameters
80
+ ----------
81
+ Takes arguments from the instance
82
+
83
+ Returns
84
+ -------
85
+ X_elem:numpy.array, representing the matrix(tensor) of all elementary components
86
+ """
87
+ X_elem = np.array( [self.Sigma[i] * np.outer(self.U[:,i], self.V[:,i]) for i in range(0,self.d)] )
88
+ return X_elem
89
+
90
+
91
+
92
+
93
+ def X_to_TS(self, X_i):
94
+ """
95
+ Averages the anti-diagonals of the given elementary matrix, X_i,
96
+ and returns a reconstructed time series for the given component X_i.
97
+
98
+ Parameters
99
+ ----------
100
+ X_i: numpy.array, elementary matrix for the given component
101
+
102
+ Returns
103
+ -------
104
+ rec_ts: numpy.array, reconstructed time series for the given component X_i
105
+ """
106
+
107
+ # Reverse the column ordering of X_i
108
+ X_rev = X_i[::-1]
109
+ rec_ts = np.array([X_rev.diagonal(i).mean() for i in range(-X_i.shape[0]+1, X_i.shape[1])])
110
+ return rec_ts
111
+
112
+ def fit(self, df, L, decomposition,idx_start_ts, **kwargs ):
113
+ """
114
+ Fits the instance of MSSA to the data
115
+ Parameters
116
+ ----------
117
+ df:pandas.DataFrame, data frame of the time series, it is a source of the data, used to infer the length of each time series N
118
+ and number of time series S in the data set,
119
+ Dataframe should have the following structure:
120
+ the rows must contain values for each sample, while the column contain the data for a particular time period.
121
+ (WIDE FORMAT)
122
+
123
+ L:int, Window Size, the most important parameter for the (M)SSA.
124
+
125
+ decomposition:str, type of decomposition of the trajectory matrix.
126
+ Available options are "svd" meaning full svd-decomposition, and "rand_svd" meaning its
127
+ "truncated version".
128
+
129
+ idx_start_ts:int, the first column index of the data frame where the first nummeric value occours,
130
+ i.e where the time series start(s). Used to cutoff irrelevant columns from the input data set.
131
+
132
+
133
+ Returns
134
+ -------
135
+ No output
136
+ """
137
+
138
+ self.decomposition = decomposition
139
+ self.df = df
140
+ self.idx_start_ts = idx_start_ts
141
+ self.ts_df = self.df.iloc[:,self.idx_start_ts:].T
142
+
143
+
144
+ self.S = self.ts_df.shape[1]
145
+ self.X_s = self.ts_df.to_numpy()
146
+ self.N = int(self.X_s.shape[0])
147
+ self.L = int(self.X_s.shape[0]/1.5) if (L == None) else L
148
+ self.K = int( self.N - self.L + 1 )
149
+
150
+ if self.Verbose==True:
151
+ print(f' N = {self.N}, K = {self.K}, L = {self.L}, S = {self.S} ')
152
+
153
+ self.X_ss = self.construct_trajectory_matrix(**kwargs)
154
+ self.U, self.Sigma, self.V = self.decompose_trajectory_matrix(**kwargs)
155
+ self.X_elem = self.elementary_matrix(**kwargs)
156
+ self.sigma_sumsq = (self.Sigma**2).sum()
157
+ self.rel_contribution = self.Sigma**2 / self.sigma_sumsq * 100
158
+ self.cumsum_contr = (self.Sigma**2).cumsum() / self.sigma_sumsq * 100
159
+
160
+
161
+ def plot_eigenvals_contribution(self,**kwargs):
162
+ """
163
+ Plots the contribution of eigenvalues
164
+ Parameters
165
+ ----------
166
+ **kwargs some additional parameters for visualization functions of pyplot
167
+ Returns
168
+ -------
169
+ No output
170
+ """
171
+
172
+
173
+ fig, ax = plt.subplots(1, 2, figsize=(14,5),**kwargs)
174
+ ax[0].plot(self.rel_contribution , lw=2.5,**kwargs)
175
+ ax[0].set_xlim(0,self.d)
176
+ ax[0].grid()
177
+ ax[0].set_title(r"Relative Contribution of $\mathbf{X}_i$ to Trajectory Matrix")
178
+ ax[0].set_xlabel(r"$i$")
179
+ ax[0].set_ylabel("Contribution (%)")
180
+ ax[1].plot(self.cumsum_contr, lw=2.5,**kwargs)
181
+ ax[1].set_xlim(0,self.d)
182
+ ax[1].set_title(r"Cumulative Contribution of $\mathbf{X}_i$ to Trajectory Matrix")
183
+ ax[1].set_xlabel(r"$i$")
184
+ ax[1].grid()
185
+ ax[1].set_ylabel(r"Contribution (%)");
186
+
187
+ def plot_eigenvectors(self,i_start, i_end,**kwargs):
188
+ """
189
+ Plots the several eigenvectors U with indices in [i_start, i_end]
190
+ Parameters
191
+ ----------
192
+ i_start: int, the first eigenvector to plot
193
+ i_end: int the last eigenvector to plot
194
+ **kwargs some additional parameters for visualization functions of pyplot
195
+ Returns
196
+ -------
197
+ No output
198
+ """
199
+ plt.figure(figsize=(25, 25),**kwargs)
200
+ for i in range(i_start, i_end):
201
+ if i_end%2!=0:
202
+ plt.subplot(2,i_end//2+1, i+1-i_start,**kwargs)
203
+ else:
204
+ plt.subplot(2,i_end//2 , i+1-i_start,**kwargs)
205
+
206
+ title = r" $\mathbf{U}_{" + str(i) + "} $" +f' {self.rel_contribution[i]} %'
207
+ plt.plot(self.U[:, i],**kwargs)
208
+ plt.title(title)
209
+ plt.tight_layout()
210
+
211
+
212
+ def construct_hankel_weights(self):
213
+ """
214
+ Constructs hankel weights for the weighted correlation matrix
215
+ Parameters
216
+ ----------
217
+
218
+ Returns
219
+ -------
220
+ w: numpy.array, weights used further for the computation of the weighted correlation matrix
221
+ """
222
+ L_ = np.minimum(self.L, self.K)
223
+ K_ = np.maximum(self.L, self.K)
224
+
225
+ weights = []
226
+ for i in range(self.N):
227
+ if i <= (L_ - 1):
228
+ weights.append(i+1)
229
+ elif i <= K_:
230
+ weights.append(L_)
231
+ else:
232
+ weights.append(self.N - i)
233
+
234
+ weights = np.array(weights)
235
+ return weights
236
+
237
+ def compute_weighted_correlation_matrix(self):
238
+ """
239
+ Computes the weighted correlation matrix used for component grouping
240
+ Parameters
241
+ ----------
242
+
243
+ Returns
244
+ -------
245
+ Wcorr: numpy.array, weighted correlation matrix used for component grouping
246
+ """
247
+ w = self.construct_hankel_weights()
248
+
249
+ TS_elem = np.zeros((self.N,self.d)).T
250
+ for s in range(self.S):
251
+ TS_elem += np.array([self.X_to_TS(self.X_elem[i,:,s*self.K:(s+1)*self.K ]) for i in range(self.d)])
252
+
253
+ TS_wnorms = np.array([w.dot((TS_elem[i]**2)) for i in range(self.d)])
254
+ TS_wnorms = TS_wnorms**-0.5
255
+ Wcorr = np.identity(self.d)
256
+ for i in range(self.d):
257
+ for j in range(i+1,self.d):
258
+ Wcorr[i,j] = abs(w.dot(TS_elem[i] * TS_elem[j])*TS_wnorms[i] * TS_wnorms[j])
259
+ Wcorr[j,i] = Wcorr[i,j]
260
+ return Wcorr
261
+
262
+ def plot_weighted_correlation_matrix(self):
263
+ """
264
+ Plots the weighted correlation matrix used for component grouping
265
+ Parameters
266
+ ----------
267
+
268
+ Returns
269
+ -------
270
+ None
271
+ """
272
+ W_corr = self.compute_weighted_correlation_matrix()
273
+ ax = plt.imshow(W_corr)
274
+ plt.xlabel(r"$\tilde{Ts}_i$")
275
+ plt.ylabel(r"$\tilde{Ts}_j$")
276
+ plt.colorbar(ax.colorbar, fraction=0.045)
277
+ ax.colorbar.set_label("$W_{ij}$")
278
+ plt.xlim(0,self.d-0.5)
279
+ plt.ylim(self.d-0.5,0)
280
+ plt.xticks(np.arange(self.d))
281
+ plt.yticks(np.arange(self.d))
282
+ plt.clim(0,1)
283
+ plt.title(f"The Weighted Correlation Matrix for the Time Series ");
284
+
285
+
286
+
287
+ def reconstruct_ts(self, idx_chosen_components, return_as_df=False):
288
+ """
289
+ Reconstructs the time series from the chosen components
290
+ Parameters
291
+ ----------
292
+ idx_chosen_components:list or numpy.arange of positive integer numbers, denotes the indices of elementary components used for the reconstruction
293
+ return_as_df:bool, whether to return the resulting reconstructed time series as pandas.DataFrame
294
+
295
+ Returns
296
+ -------
297
+ ts_rec: numpy.array or pandas.DataFrame, reconstructed time series
298
+ """
299
+ chosen_components = self.X_elem[0,:,self.K]
300
+ chosen_components = 0
301
+ for i in idx_chosen_components:
302
+ chosen_components += self.X_elem[i,:,:]
303
+
304
+
305
+ ts_rec = self.X_s
306
+ ts_rec[:,:] = 0
307
+ for s in range(0,self.S):
308
+ ts_rec[:,s] = self.X_to_TS(chosen_components[:,s*self.K:(s+1)*self.K])
309
+
310
+ if return_as_df==True:
311
+ return pd.DataFrame(columns=self.df.columns, data=np.column_stack([self.df.iloc[:,:self.idx_start_ts ].values,ts_rec.T ]))
312
+ return ts_rec.T
313
+
314
+
315
+
316
+
317
+ def estimate_LRR(self, idx_components):
318
+ """
319
+ Estimates Linear Recurrence Relations(LRR) coefficients for the MSSA, which is used for forecasting
320
+ Parameters
321
+ ----------
322
+ idx_components:list or numpy.arange of positive integer numbers, denotes the indices of elementary components
323
+
324
+ Returns
325
+ -------
326
+ R: numpy.array, Linear Recurrence Relations(LRR) coefficients
327
+ """
328
+
329
+
330
+ P_orth = sp.linalg.qr(self.U[:,:self.d])[0]#self.L
331
+ nu_sq = np.sum(P_orth[-1,idx_components]**2)
332
+
333
+ if nu_sq !=1:
334
+ R = 1/(1-nu_sq) * (P_orth[:-1,idx_components] @ P_orth[-1,idx_components] )
335
+
336
+
337
+ return R
338
+ else :
339
+ print(nu_sq)
340
+
341
+ def L_Forecast(self, ts, M, idx_components, mode='forward'):
342
+ """
343
+ Forecasts or estimates M values for a given time series using LRR
344
+ Parameters
345
+ ----------
346
+ idx_components:list or numpy.arange of positive integer numbers, denotes the indices of elementary components
347
+ ts: numpy.array, input time series
348
+ M:int, number of values to forecast or estimate
349
+ mode:str, forecasts M future values for S time series if mode is "forward", or estimates the last M values for S input time series, if mode is 'retrospective'
350
+
351
+ Returns
352
+ -------
353
+ y_pred: numpy.array, original time series + M forecasted values, or original time series, where the last M values are estimated
354
+ """
355
+ R = self.estimate_LRR(idx_components)
356
+ R = R.reshape(-1,1)
357
+ if M<=0:
358
+ y_pred = ts[:,:self.N]
359
+ return y_pred
360
+
361
+ if mode == 'forward':
362
+ y_pred = np.zeros((ts.shape[0],self.N+M))
363
+ y_pred[:,:self.N] = ts[:,:self.N]
364
+
365
+ for m in range(0,M):
366
+ y_pred[:,self.N+m] = (y_pred[:,self.N-self.L+m+1:y_pred.shape[1]-M+m]@R).flatten()
367
+
368
+ elif mode == 'retrospective':
369
+ y_pred = np.zeros((ts.shape[0],self.N))
370
+ y_pred[:,:self.N-M] = ts[:,:self.N-M]
371
+
372
+ for m in range(0,M):
373
+
374
+ y_pred[:,self.N+m-M] = (y_pred[:,self.N-self.L+m+1-M:y_pred.shape[1]-M+m] @ R).flatten()
375
+
376
+
377
+ else:
378
+
379
+ raise ValueError('Wrong type of the forecasting mode')
380
+
381
+
382
+
383
+ return y_pred
384
+
385
+ def estimate_ESPRIT(self, idx_components=[0], decompose_rho_omega=False):
386
+
387
+ """
388
+ Estimates polynomial roots of the signal using ESPRIT algorithm and LS
389
+ Parameters
390
+ ----------
391
+ idx_components:list or numpy.arange of positive integer numbers, denotes the indices of elementary components used
392
+ decompose_rho_omega: bool, decompose the roots into real and imaginery part
393
+ Returns
394
+ -------
395
+ mu: numpy.array, complex polynomial roots
396
+ rho: numpy.array, real polynomial roots
397
+ omega: numpy.array, complex part polynomial roots
398
+ """
399
+ # P_orth = self.U[:,idx_components]
400
+
401
+ P_orth = sp.linalg.qr(self.U[:,:self.L])[0]
402
+
403
+ P_ = P_orth[:-1,idx_components] #last row removed
404
+ _P = P_orth[1:,idx_components] #first row removed
405
+
406
+ Inv_ = np.linalg.inv(P_.T @ P_) @ P_.T
407
+ MM = Inv_ @ _P
408
+ mu = np.flip(np.sort( np.linalg.eigvals(MM)))
409
+ if decompose_rho_omega == True:
410
+ rho = np.imag(mu)
411
+ omega = np.real(mu)
412
+ return mu, rho, omega
413
+ else:
414
+ return mu
415
+
416
+ def plot_polynomial_roots(self, idx_components):
417
+ """
418
+ Plots estimated polynomial roots of the signal using ESPRIT algorithm and LS
419
+ Parameters
420
+ ----------
421
+ idx_components:list or numpy.arange of positive integer numbers, denotes the indices of elementary components used
422
+ decompose_rho_omega: bool, decompose the roots into real and imaginery part
423
+ Returns
424
+ -------
425
+
426
+ """
427
+ _,rho, omega = self.estimate_ESPRIT(idx_components=idx_components, decompose_rho_omega=True )
428
+ fig, ax = plt.subplots()
429
+ unit_circle = plt.Circle((0, 0), 1, color='b', fill=False)
430
+ ax.add_patch(unit_circle)
431
+
432
+ plt.title("Polynomial roots on the unit circle")
433
+ ax.plot( omega, rho, "r*")
434
+ plt.gca().set_aspect('equal')
435
+ ax.set_xlabel('Real Part')
436
+ ax.set_ylabel('Imaginary Part')
437
+ plt.show()
438
+