pochep-procmat 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.
- pochep_procmat-0.0.1/.gitignore +6 -0
- pochep_procmat-0.0.1/CHANGELOG.md +8 -0
- pochep_procmat-0.0.1/CITATION.cff +26 -0
- pochep_procmat-0.0.1/LICENSE +21 -0
- pochep_procmat-0.0.1/PKG-INFO +70 -0
- pochep_procmat-0.0.1/README.md +44 -0
- pochep_procmat-0.0.1/README.ru.md +34 -0
- pochep_procmat-0.0.1/docs/en/api.md +13 -0
- pochep_procmat-0.0.1/docs/en/theory.md +25 -0
- pochep_procmat-0.0.1/docs/ru/api.md +13 -0
- pochep_procmat-0.0.1/docs/ru/theory.md +32 -0
- pochep_procmat-0.0.1/pyproject.toml +54 -0
- pochep_procmat-0.0.1/src/pochep_procmat/__init__.py +38 -0
- pochep_procmat-0.0.1/src/pochep_procmat/core.py +204 -0
- pochep_procmat-0.0.1/src/pochep_procmat/model.py +134 -0
- pochep_procmat-0.0.1/src/pochep_procmat/py.typed +0 -0
- pochep_procmat-0.0.1/src/pochep_procmat/synthetic.py +49 -0
- pochep_procmat-0.0.1/tests/test_core.py +121 -0
- pochep_procmat-0.0.1/tests/test_model.py +60 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.1
|
|
4
|
+
|
|
5
|
+
- First release. QR, SVD, Cholesky normal equations, NNLS and NMF solvers for `M t = p`.
|
|
6
|
+
- `ProcessMemoryModel` with `fit`, `predict` and `FitResult`.
|
|
7
|
+
- Synthetic generator `make_process_profiles` for tests and the demo.
|
|
8
|
+
- Input validators raising `TypeError` or `ValueError`. 24 tests on Python 3.14.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use this software, please cite it as below."
|
|
3
|
+
title: "pochep-procmat: matrix decompositions for the process memory model M t = p"
|
|
4
|
+
type: software
|
|
5
|
+
version: 0.0.1
|
|
6
|
+
date-released: 2026-11-13
|
|
7
|
+
license: MIT
|
|
8
|
+
repository-code: "https://github.com/89605502155/pochep-procmat"
|
|
9
|
+
url: "https://pypi.org/project/pochep-procmat/"
|
|
10
|
+
authors:
|
|
11
|
+
- family-names: Ferubko
|
|
12
|
+
given-names: Andrey O.
|
|
13
|
+
affiliation: "Bryansk State University of Engineering and Technology"
|
|
14
|
+
email: ferubko1999@yandex.ru
|
|
15
|
+
orcid: "https://orcid.org/0009-0006-5625-137X"
|
|
16
|
+
- family-names: Kazakov
|
|
17
|
+
given-names: Oleg D.
|
|
18
|
+
affiliation: "Bryansk State University of Engineering and Technology"
|
|
19
|
+
email: kazakov@bgitu.ru
|
|
20
|
+
orcid: "https://orcid.org/0000-0001-9665-8138"
|
|
21
|
+
keywords:
|
|
22
|
+
- matrix decomposition
|
|
23
|
+
- SVD
|
|
24
|
+
- QR
|
|
25
|
+
- NMF
|
|
26
|
+
- process memory
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oleg D. Kazakov, Andrey O. Ferubko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pochep-procmat
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Matrix decompositions (QR, SVD, Cholesky, NNLS, NMF) for the process memory model M t = p
|
|
5
|
+
Project-URL: Homepage, https://github.com/89605502155/pochep-procmat
|
|
6
|
+
Project-URL: Repository, https://github.com/89605502155/pochep-procmat
|
|
7
|
+
Project-URL: Demo, https://github.com/89605502155/pochep-procmat-demo
|
|
8
|
+
Project-URL: Changelog, https://github.com/89605502155/pochep-procmat/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: "Andrey O. Ferubko" <ferubko1999@yandex.ru>, "Oleg D. Kazakov" <kazakov@bgitu.ru>
|
|
10
|
+
Maintainer-email: "Andrey O. Ferubko" <ferubko1999@yandex.ru>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: NMF,QR,SVD,linear model,matrix decomposition,memory,process
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: numpy>=2.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# pochep-procmat
|
|
28
|
+
|
|
29
|
+
Matrix decompositions for the process memory model `M t = p`.
|
|
30
|
+
|
|
31
|
+
Rows of `M` are timestamps and columns are processes. `p` is the total memory measured at each
|
|
32
|
+
timestamp and `t` is the vector of per-process weights. The package solves this linear system
|
|
33
|
+
with QR, SVD, Cholesky normal equations, non-negative least squares (NNLS), and fits NMF
|
|
34
|
+
factors `M ~ W H`. It also provides a synthetic generator of process profiles for tests.
|
|
35
|
+
|
|
36
|
+
Russian version: [README.ru.md](README.ru.md). Documentation: [docs/en](docs/en/theory.md), [docs/ru](docs/ru/theory.md).
|
|
37
|
+
Demo notebook with strengths and weaknesses: https://github.com/89605502155/pochep-procmat-demo
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install pochep-procmat
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Python 3.11 to 3.14 and NumPy 2.0 or newer. Released on PyPI: https://pypi.org/project/pochep-procmat/
|
|
46
|
+
|
|
47
|
+
## Example
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pochep_procmat import ProcessMemoryModel, make_process_profiles
|
|
51
|
+
|
|
52
|
+
M, t_true, p = make_process_profiles(n_times=120, n_processes=4, seed=1, noise=0.01)
|
|
53
|
+
model = ProcessMemoryModel("svd")
|
|
54
|
+
result = model.fit(M, p)
|
|
55
|
+
print(result.weights, result.relative_residual, result.rank)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Methods
|
|
59
|
+
|
|
60
|
+
| Method | Function | Needs | Output |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| `qr` | `solve_qr` | full column rank | least-squares weights |
|
|
63
|
+
| `svd` | `solve_svd` | none, truncation by `rcond` | minimum-norm weights |
|
|
64
|
+
| `cholesky` | `solve_normal_cholesky` | positive definite `M^T M`, or `ridge > 0` | weights |
|
|
65
|
+
| `nnls` | `solve_nnls` | none | non-negative weights |
|
|
66
|
+
| `nmf` | `nmf`, then NNLS | non-negative `M` and `p` | non-negative weights from `W H` |
|
|
67
|
+
|
|
68
|
+
## Licence
|
|
69
|
+
|
|
70
|
+
MIT. Authors: Andrey O. Ferubko, Oleg D. Kazakov. How to cite: see `CITATION.cff`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# pochep-procmat
|
|
2
|
+
|
|
3
|
+
Matrix decompositions for the process memory model `M t = p`.
|
|
4
|
+
|
|
5
|
+
Rows of `M` are timestamps and columns are processes. `p` is the total memory measured at each
|
|
6
|
+
timestamp and `t` is the vector of per-process weights. The package solves this linear system
|
|
7
|
+
with QR, SVD, Cholesky normal equations, non-negative least squares (NNLS), and fits NMF
|
|
8
|
+
factors `M ~ W H`. It also provides a synthetic generator of process profiles for tests.
|
|
9
|
+
|
|
10
|
+
Russian version: [README.ru.md](README.ru.md). Documentation: [docs/en](docs/en/theory.md), [docs/ru](docs/ru/theory.md).
|
|
11
|
+
Demo notebook with strengths and weaknesses: https://github.com/89605502155/pochep-procmat-demo
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install pochep-procmat
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Python 3.11 to 3.14 and NumPy 2.0 or newer. Released on PyPI: https://pypi.org/project/pochep-procmat/
|
|
20
|
+
|
|
21
|
+
## Example
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from pochep_procmat import ProcessMemoryModel, make_process_profiles
|
|
25
|
+
|
|
26
|
+
M, t_true, p = make_process_profiles(n_times=120, n_processes=4, seed=1, noise=0.01)
|
|
27
|
+
model = ProcessMemoryModel("svd")
|
|
28
|
+
result = model.fit(M, p)
|
|
29
|
+
print(result.weights, result.relative_residual, result.rank)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Methods
|
|
33
|
+
|
|
34
|
+
| Method | Function | Needs | Output |
|
|
35
|
+
|---|---|---|---|
|
|
36
|
+
| `qr` | `solve_qr` | full column rank | least-squares weights |
|
|
37
|
+
| `svd` | `solve_svd` | none, truncation by `rcond` | minimum-norm weights |
|
|
38
|
+
| `cholesky` | `solve_normal_cholesky` | positive definite `M^T M`, or `ridge > 0` | weights |
|
|
39
|
+
| `nnls` | `solve_nnls` | none | non-negative weights |
|
|
40
|
+
| `nmf` | `nmf`, then NNLS | non-negative `M` and `p` | non-negative weights from `W H` |
|
|
41
|
+
|
|
42
|
+
## Licence
|
|
43
|
+
|
|
44
|
+
MIT. Authors: Andrey O. Ferubko, Oleg D. Kazakov. How to cite: see `CITATION.cff`.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# pochep-procmat
|
|
2
|
+
|
|
3
|
+
Матричные разложения для модели потребления памяти процессов `M t = p`.
|
|
4
|
+
|
|
5
|
+
Строки `M` соответствуют моментам времени, столбцы процессам. `p` это суммарная измеренная память
|
|
6
|
+
в каждый момент, `t` вектор весов процессов. Пакет решает эту линейную систему через QR, SVD,
|
|
7
|
+
нормальные уравнения с разложением Холецкого и неотрицательный МНК (NNLS), а также строит
|
|
8
|
+
неотрицательное разложение `M ~ W H` (NMF). Есть генератор синтетических профилей процессов для тестов.
|
|
9
|
+
|
|
10
|
+
Английская версия: [README.md](README.md). Документация: [docs/ru](docs/ru/theory.md), [docs/en](docs/en/theory.md).
|
|
11
|
+
Демонстрационный ноутбук с сильными и слабыми сторонами: https://github.com/89605502155/pochep-procmat-demo
|
|
12
|
+
|
|
13
|
+
## Установка
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install pochep-procmat
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Требуется Python 3.11–3.14 и NumPy 2.0 или новее. Страница на PyPI: https://pypi.org/project/pochep-procmat/
|
|
20
|
+
|
|
21
|
+
## Пример
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from pochep_procmat import ProcessMemoryModel, make_process_profiles
|
|
25
|
+
|
|
26
|
+
M, t_true, p = make_process_profiles(n_times=120, n_processes=4, seed=1, noise=0.01)
|
|
27
|
+
model = ProcessMemoryModel("svd")
|
|
28
|
+
result = model.fit(M, p)
|
|
29
|
+
print(result.weights, result.relative_residual, result.rank)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Лицензия
|
|
33
|
+
|
|
34
|
+
MIT. Авторы: Ферубко Андрей Олегович, Казаков Олег Дмитриевич. Как цитировать: см. `CITATION.cff`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# pochep_procmat API (version 0.0.1)
|
|
2
|
+
|
|
3
|
+
- `ProcessMemoryModel(method="svd", *, rcond=1e-10, ridge=0.0, nmf_rank=None, seed=0)`: the model `M t = p`.
|
|
4
|
+
`fit(M, p)` returns a `FitResult`, `predict(M)` returns `M @ t`, property `weights`.
|
|
5
|
+
- `FitResult`: `method`, `weights`, `residual_norm`, `relative_residual`, `rank`, `seconds`.
|
|
6
|
+
- `solve_qr(M, p)`, `solve_svd(M, p, rcond=1e-10)`, `solve_normal_cholesky(M, p, ridge=0.0)`,
|
|
7
|
+
`solve_nnls(M, p, *, max_iter=20000, tol=1e-12)`.
|
|
8
|
+
- `qr_decompose(M)`, `svd_decompose(M)`, `nmf(M, rank, *, max_iter=500, tol=1e-6, seed=0)`.
|
|
9
|
+
- `effective_rank(M, rtol=1e-10)`, `cosine_similarity(a, b)`.
|
|
10
|
+
- `make_process_profiles(n_times, n_processes, *, seed=0, noise=0.02, mean_block_mb=150.0)`
|
|
11
|
+
returns `(M, t_true, p)` of synthetic data.
|
|
12
|
+
- `as_matrix`, `as_vector`: input checks. A wrong type raises `TypeError`, a wrong shape, an empty array
|
|
13
|
+
or NaN raises `ValueError`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Theory: the model M t = p and matrix decompositions
|
|
2
|
+
|
|
3
|
+
`M` is a T × r matrix: a row is a timestamp, a column is a process, and an element is the memory
|
|
4
|
+
consumed by that process at that timestamp. The vector `p` of length T holds the total measured memory.
|
|
5
|
+
The vector `t` of length r holds unknown process weights. The model `M t = p` looks for `t` such that
|
|
6
|
+
the weighted sum of the process profiles matches the measurement.
|
|
7
|
+
|
|
8
|
+
If `T > r` and the columns of `M` are linearly independent, the least-squares solution is unique.
|
|
9
|
+
Then QR gives `t = R^{-1} Q^T p` with `M = Q R`, where `Q` has orthonormal columns and `R` is upper
|
|
10
|
+
triangular. If the columns are dependent, `R` is singular and QR cannot give a solution. The SVD
|
|
11
|
+
`M = U diag(s) V^T` then gives the minimum-norm solution `V diag(1/s) U^T p`, where singular values
|
|
12
|
+
below `rcond` are dropped.
|
|
13
|
+
|
|
14
|
+
The normal equations `(M^T M + λ I) t = M^T p` are solved by Cholesky factorisation. A positive ridge `λ`
|
|
15
|
+
makes the system solvable when `M` is rank deficient. The price is that the condition number is squared.
|
|
16
|
+
|
|
17
|
+
If the process weights must be non-negative, the problem `min ||M t - p||` with `t >= 0` (NNLS) is solved
|
|
18
|
+
by projected gradient descent with step `1/L`, where `L` is the largest eigenvalue of `M^T M`.
|
|
19
|
+
|
|
20
|
+
Non-negative matrix factorisation (NMF) finds `M ~ W H` with `W >= 0` and `H >= 0`. Columns of `W` can be
|
|
21
|
+
read as latent profiles and rows of `H` as their shares in each process. The factors come from Lee and Seung
|
|
22
|
+
multiplicative updates. The factorisation is not unique, so the result depends on the start `seed`.
|
|
23
|
+
|
|
24
|
+
The package does not guarantee that recovered weights match real processes. That is the hypothesis of the
|
|
25
|
+
experiment, not a property of the decompositions. See `НОВИЗНА.md` and `АНАЛИЗ.md` of the article folder.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# API pochep_procmat (версия 0.0.1)
|
|
2
|
+
|
|
3
|
+
- `ProcessMemoryModel(method="svd", *, rcond=1e-10, ridge=0.0, nmf_rank=None, seed=0)`: модель `M t = p`.
|
|
4
|
+
Методы `fit(M, p)` возвращает `FitResult`, `predict(M)` возвращает `M @ t`, свойство `weights`.
|
|
5
|
+
- `FitResult`: `method`, `weights`, `residual_norm`, `relative_residual`, `rank`, `seconds`.
|
|
6
|
+
- `solve_qr(M, p)`, `solve_svd(M, p, rcond=1e-10)`, `solve_normal_cholesky(M, p, ridge=0.0)`,
|
|
7
|
+
`solve_nnls(M, p, *, max_iter=20000, tol=1e-12)`.
|
|
8
|
+
- `qr_decompose(M)`, `svd_decompose(M)`, `nmf(M, rank, *, max_iter=500, tol=1e-6, seed=0)`.
|
|
9
|
+
- `effective_rank(M, rtol=1e-10)`, `cosine_similarity(a, b)`.
|
|
10
|
+
- `make_process_profiles(n_times, n_processes, *, seed=0, noise=0.02, mean_block_mb=150.0)`
|
|
11
|
+
возвращает `(M, t_true, p)` синтетических данных.
|
|
12
|
+
- `as_matrix`, `as_vector`: проверка входа. Неверный тип вызывает `TypeError`, неверная форма,
|
|
13
|
+
пустой массив или NaN вызывают `ValueError`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Теория: модель M t = p и матричные разложения
|
|
2
|
+
|
|
3
|
+
Пусть `M` матрица размера T × r: строка соответствует моменту времени, столбец процессу, элемент
|
|
4
|
+
равен потреблению памяти процессом в этот момент. Вектор `p` длины T содержит суммарное потребление
|
|
5
|
+
памяти. Вектор `t` длины r содержит неизвестные веса процессов. Модель `M t = p` ищет `t` так,
|
|
6
|
+
чтобы взвешенная сумма профилей процессов совпала с измерением.
|
|
7
|
+
|
|
8
|
+
Если `T > r` и столбцы `M` линейно независимы, решение по методу наименьших квадратов единственно.
|
|
9
|
+
В этом случае решение через QR дает `t = R^{-1} Q^T p`, где `M = Q R`, `Q` имеет ортонормированные
|
|
10
|
+
столбцы, `R` верхнетреугольная. Если столбцы зависимы, `R` вырождена, и решение через QR не существует.
|
|
11
|
+
Тогда используется SVD: `M = U diag(s) V^T`, и минимальное по норме решение равно `V diag(1/s) U^T p`,
|
|
12
|
+
где малые сингулярные значения отбрасываются по порогу `rcond`.
|
|
13
|
+
|
|
14
|
+
Метод нормальных уравнений `(M^T M + λ I) t = M^T p` решается разложением Холецкого. Положительный
|
|
15
|
+
параметр `λ` (ridge) делает систему разрешимой при вырожденной `M`. Цена метода в том, что число
|
|
16
|
+
обусловленности возводится в квадрат.
|
|
17
|
+
|
|
18
|
+
Если веса процессов должны быть неотрицательными, решается задача `min ||M t - p||` при `t >= 0`
|
|
19
|
+
(NNLS). В пакете она решается проекционным градиентом с шагом `1/L`, где `L` наибольшее собственное
|
|
20
|
+
значение `M^T M`.
|
|
21
|
+
|
|
22
|
+
Неотрицательное матричное разложение (NMF) ищет `M ~ W H` с `W >= 0` и `H >= 0`. Столбцы `W`
|
|
23
|
+
можно интерпретировать как латентные профили, а строки `H` как доли этих профилей в процессах.
|
|
24
|
+
Разложение находится итерациями Ли и Сына (мультипликативные обновления). Оно не единственно,
|
|
25
|
+
поэтому результат зависит от начального приближения `seed`.
|
|
26
|
+
|
|
27
|
+
Квантор всеобщности (∀) здесь означает: для любого момента времени t_i выполняется уравнение
|
|
28
|
+
Σ_j M_ij t_j = p_i с точностью до шума. Квантор существования (∃) означает: существует такой
|
|
29
|
+
вектор весов t ≥ 0, что ||M t - p|| минимально (задача NNLS).
|
|
30
|
+
|
|
31
|
+
Пакет не гарантирует, что найденные веса соответствуют реальным процессам. Это гипотеза эксперимента,
|
|
32
|
+
а не свойство разложений. См. `НОВИЗНА.md` и `АНАЛИЗ.md` статьи.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pochep-procmat"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Matrix decompositions (QR, SVD, Cholesky, NNLS, NMF) for the process memory model M t = p"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Andrey O. Ferubko", email = "ferubko1999@yandex.ru" },
|
|
15
|
+
{ name = "Oleg D. Kazakov", email = "kazakov@bgitu.ru" },
|
|
16
|
+
]
|
|
17
|
+
maintainers = [
|
|
18
|
+
{ name = "Andrey O. Ferubko", email = "ferubko1999@yandex.ru" },
|
|
19
|
+
]
|
|
20
|
+
keywords = ["matrix decomposition", "SVD", "QR", "NMF", "memory", "process", "linear model"]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 3 - Alpha",
|
|
23
|
+
"Intended Audience :: Science/Research",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Programming Language :: Python :: 3.14",
|
|
30
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
31
|
+
]
|
|
32
|
+
dependencies = ["numpy>=2.0"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/89605502155/pochep-procmat"
|
|
36
|
+
Repository = "https://github.com/89605502155/pochep-procmat"
|
|
37
|
+
Demo = "https://github.com/89605502155/pochep-procmat-demo"
|
|
38
|
+
Changelog = "https://github.com/89605502155/pochep-procmat/blob/main/CHANGELOG.md"
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/pochep_procmat"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 110
|
|
48
|
+
target-version = "py311"
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["E", "F", "W", "I", "B", "UP"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""pochep_procmat: matrix decompositions for the process memory model M t = p."""
|
|
2
|
+
|
|
3
|
+
from pochep_procmat.core import (
|
|
4
|
+
as_matrix,
|
|
5
|
+
as_vector,
|
|
6
|
+
cosine_similarity,
|
|
7
|
+
effective_rank,
|
|
8
|
+
nmf,
|
|
9
|
+
qr_decompose,
|
|
10
|
+
solve_nnls,
|
|
11
|
+
solve_normal_cholesky,
|
|
12
|
+
solve_qr,
|
|
13
|
+
solve_svd,
|
|
14
|
+
svd_decompose,
|
|
15
|
+
)
|
|
16
|
+
from pochep_procmat.model import METHODS, FitResult, ProcessMemoryModel
|
|
17
|
+
from pochep_procmat.synthetic import make_process_profiles
|
|
18
|
+
|
|
19
|
+
__version__ = "0.0.1"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"METHODS",
|
|
23
|
+
"FitResult",
|
|
24
|
+
"ProcessMemoryModel",
|
|
25
|
+
"__version__",
|
|
26
|
+
"as_matrix",
|
|
27
|
+
"as_vector",
|
|
28
|
+
"cosine_similarity",
|
|
29
|
+
"effective_rank",
|
|
30
|
+
"make_process_profiles",
|
|
31
|
+
"nmf",
|
|
32
|
+
"qr_decompose",
|
|
33
|
+
"solve_nnls",
|
|
34
|
+
"solve_normal_cholesky",
|
|
35
|
+
"solve_qr",
|
|
36
|
+
"solve_svd",
|
|
37
|
+
"svd_decompose",
|
|
38
|
+
]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Matrix decompositions and solvers for the linear model M @ t = p.
|
|
2
|
+
|
|
3
|
+
Every public function validates its inputs and raises ``TypeError`` or
|
|
4
|
+
``ValueError`` on a wrong type, shape or non-finite value.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.typing import ArrayLike, NDArray
|
|
11
|
+
|
|
12
|
+
FloatArray = NDArray[np.float64]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def as_matrix(M: ArrayLike, name: str = "M") -> FloatArray:
|
|
16
|
+
"""Return ``M`` as a finite two-dimensional float64 array.
|
|
17
|
+
|
|
18
|
+
Raises ``TypeError`` if ``M`` cannot be converted to floats and ``ValueError``
|
|
19
|
+
if it is not two-dimensional, is empty or contains NaN or infinite values.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
arr = np.asarray(M, dtype=np.float64)
|
|
23
|
+
except (TypeError, ValueError) as exc:
|
|
24
|
+
raise TypeError(f"{name} must be convertible to a float array") from exc
|
|
25
|
+
if arr.ndim != 2:
|
|
26
|
+
raise ValueError(f"{name} must be two-dimensional, got {arr.ndim} dimensions")
|
|
27
|
+
if arr.size == 0:
|
|
28
|
+
raise ValueError(f"{name} must not be empty")
|
|
29
|
+
if not np.all(np.isfinite(arr)):
|
|
30
|
+
raise ValueError(f"{name} contains NaN or infinite values")
|
|
31
|
+
return arr
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def as_vector(p: ArrayLike, name: str = "p", length: int | None = None) -> FloatArray:
|
|
35
|
+
"""Return ``p`` as a finite one-dimensional float64 array of the given length."""
|
|
36
|
+
try:
|
|
37
|
+
arr = np.asarray(p, dtype=np.float64)
|
|
38
|
+
except (TypeError, ValueError) as exc:
|
|
39
|
+
raise TypeError(f"{name} must be convertible to a float array") from exc
|
|
40
|
+
if arr.ndim != 1:
|
|
41
|
+
raise ValueError(f"{name} must be one-dimensional, got {arr.ndim} dimensions")
|
|
42
|
+
if length is not None and arr.shape[0] != length:
|
|
43
|
+
raise ValueError(f"{name} must have length {length}, got {arr.shape[0]}")
|
|
44
|
+
if not np.all(np.isfinite(arr)):
|
|
45
|
+
raise ValueError(f"{name} contains NaN or infinite values")
|
|
46
|
+
return arr
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _check_pair(M: ArrayLike, p: ArrayLike) -> tuple[FloatArray, FloatArray]:
|
|
50
|
+
m = as_matrix(M)
|
|
51
|
+
vec = as_vector(p, length=m.shape[0])
|
|
52
|
+
return m, vec
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def qr_decompose(M: ArrayLike) -> tuple[FloatArray, FloatArray]:
|
|
56
|
+
"""Reduced QR decomposition ``M = Q R`` with ``Q`` of shape (T, r) and ``R`` of shape (r, r)."""
|
|
57
|
+
m = as_matrix(M)
|
|
58
|
+
q, r = np.linalg.qr(m, mode="reduced")
|
|
59
|
+
return q, r
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def svd_decompose(M: ArrayLike) -> tuple[FloatArray, FloatArray, FloatArray]:
|
|
63
|
+
"""Thin singular value decomposition ``M = U diag(s) V^T``.
|
|
64
|
+
|
|
65
|
+
Returns ``(U, s, Vt)`` with ``s`` sorted in decreasing order.
|
|
66
|
+
"""
|
|
67
|
+
m = as_matrix(M)
|
|
68
|
+
u, s, vt = np.linalg.svd(m, full_matrices=False)
|
|
69
|
+
return u, s, vt
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def effective_rank(M: ArrayLike, rtol: float = 1e-10) -> int:
|
|
73
|
+
"""Number of singular values larger than ``rtol`` times the largest one."""
|
|
74
|
+
m = as_matrix(M)
|
|
75
|
+
s = np.linalg.svd(m, compute_uv=False)
|
|
76
|
+
if s[0] == 0.0:
|
|
77
|
+
return 0
|
|
78
|
+
return int(np.sum(s > rtol * s[0]))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def solve_qr(M: ArrayLike, p: ArrayLike) -> FloatArray:
|
|
82
|
+
"""Least-squares solution of ``M t = p`` through the QR decomposition.
|
|
83
|
+
|
|
84
|
+
Requires ``M`` to have full column rank. Raises ``ValueError`` otherwise.
|
|
85
|
+
"""
|
|
86
|
+
m, vec = _check_pair(M, p)
|
|
87
|
+
if effective_rank(m) < m.shape[1]:
|
|
88
|
+
raise ValueError("QR solver needs a full column rank matrix, use the svd method instead")
|
|
89
|
+
q, r = np.linalg.qr(m, mode="reduced")
|
|
90
|
+
return np.linalg.solve(r, q.T @ vec)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def solve_svd(M: ArrayLike, p: ArrayLike, rcond: float = 1e-10) -> FloatArray:
|
|
94
|
+
"""Minimum-norm least-squares solution through the truncated pseudo-inverse.
|
|
95
|
+
|
|
96
|
+
Singular values below ``rcond`` times the largest one are treated as zero.
|
|
97
|
+
"""
|
|
98
|
+
m, vec = _check_pair(M, p)
|
|
99
|
+
if rcond < 0:
|
|
100
|
+
raise ValueError("rcond must be non-negative")
|
|
101
|
+
u, s, vt = np.linalg.svd(m, full_matrices=False)
|
|
102
|
+
keep = s > rcond * s[0] if s[0] > 0 else np.zeros_like(s, dtype=bool)
|
|
103
|
+
coef = np.zeros(s.shape[0])
|
|
104
|
+
coef[keep] = (u[:, keep].T @ vec) / s[keep]
|
|
105
|
+
return vt.T @ coef
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def solve_normal_cholesky(M: ArrayLike, p: ArrayLike, ridge: float = 0.0) -> FloatArray:
|
|
109
|
+
"""Solve ``(M^T M + ridge I) t = M^T p`` with a Cholesky factorisation.
|
|
110
|
+
|
|
111
|
+
A positive ``ridge`` adds Tikhonov regularisation and makes the system solvable
|
|
112
|
+
for rank-deficient ``M``. With ``ridge = 0`` the matrix must be positive definite,
|
|
113
|
+
otherwise ``ValueError`` is raised.
|
|
114
|
+
"""
|
|
115
|
+
m, vec = _check_pair(M, p)
|
|
116
|
+
if ridge < 0:
|
|
117
|
+
raise ValueError("ridge must be non-negative")
|
|
118
|
+
gram = m.T @ m + ridge * np.eye(m.shape[1])
|
|
119
|
+
try:
|
|
120
|
+
chol = np.linalg.cholesky(gram)
|
|
121
|
+
except np.linalg.LinAlgError as exc:
|
|
122
|
+
raise ValueError("normal matrix is not positive definite, use ridge > 0") from exc
|
|
123
|
+
y = np.linalg.solve(chol, m.T @ vec)
|
|
124
|
+
return np.linalg.solve(chol.T, y)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def solve_nnls(
|
|
128
|
+
M: ArrayLike,
|
|
129
|
+
p: ArrayLike,
|
|
130
|
+
*,
|
|
131
|
+
max_iter: int = 20000,
|
|
132
|
+
tol: float = 1e-12,
|
|
133
|
+
) -> FloatArray:
|
|
134
|
+
"""Non-negative least squares ``min ||M t - p||`` subject to ``t >= 0``.
|
|
135
|
+
|
|
136
|
+
Solved by projected gradient descent with the step ``1 / L``, where ``L`` is the
|
|
137
|
+
largest eigenvalue of ``M^T M``. Stops when the relative change of ``t`` is below ``tol``.
|
|
138
|
+
"""
|
|
139
|
+
m, vec = _check_pair(M, p)
|
|
140
|
+
if max_iter < 1:
|
|
141
|
+
raise ValueError("max_iter must be at least 1")
|
|
142
|
+
gram = m.T @ m
|
|
143
|
+
lipschitz = float(np.linalg.eigvalsh(gram)[-1])
|
|
144
|
+
rhs = m.T @ vec
|
|
145
|
+
t = np.zeros(m.shape[1])
|
|
146
|
+
if lipschitz == 0.0:
|
|
147
|
+
return t
|
|
148
|
+
for _ in range(max_iter):
|
|
149
|
+
grad = gram @ t - rhs
|
|
150
|
+
t_new = np.maximum(0.0, t - grad / lipschitz)
|
|
151
|
+
change = np.linalg.norm(t_new - t)
|
|
152
|
+
t = t_new
|
|
153
|
+
if change <= tol * max(1.0, np.linalg.norm(t)):
|
|
154
|
+
break
|
|
155
|
+
return t
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def nmf(
|
|
159
|
+
M: ArrayLike,
|
|
160
|
+
rank: int,
|
|
161
|
+
*,
|
|
162
|
+
max_iter: int = 500,
|
|
163
|
+
tol: float = 1e-6,
|
|
164
|
+
seed: int = 0,
|
|
165
|
+
eps: float = 1e-12,
|
|
166
|
+
) -> tuple[FloatArray, FloatArray, FloatArray]:
|
|
167
|
+
"""Non-negative matrix factorisation ``M ~ W H`` by Lee and Seung multiplicative updates.
|
|
168
|
+
|
|
169
|
+
``M`` must be non-negative. Returns ``(W, H, errors)`` where ``W`` has shape (T, rank),
|
|
170
|
+
``H`` has shape (rank, r) and ``errors`` is the Frobenius error after each iteration.
|
|
171
|
+
The random initialisation is controlled by ``seed``.
|
|
172
|
+
"""
|
|
173
|
+
m = as_matrix(M)
|
|
174
|
+
if np.any(m < 0):
|
|
175
|
+
raise ValueError("NMF needs a non-negative matrix")
|
|
176
|
+
if not isinstance(rank, (int, np.integer)) or rank < 1:
|
|
177
|
+
raise ValueError("rank must be a positive integer")
|
|
178
|
+
if rank > min(m.shape):
|
|
179
|
+
raise ValueError("rank must not exceed min(M.shape)")
|
|
180
|
+
rng = np.random.default_rng(seed)
|
|
181
|
+
scale = np.sqrt(m.mean() / rank) if m.mean() > 0 else 1.0
|
|
182
|
+
w = np.abs(rng.standard_normal((m.shape[0], rank))) * scale + eps
|
|
183
|
+
h = np.abs(rng.standard_normal((rank, m.shape[1]))) * scale + eps
|
|
184
|
+
errors: list[float] = []
|
|
185
|
+
previous = np.inf
|
|
186
|
+
for _ in range(max_iter):
|
|
187
|
+
h *= (w.T @ m) / (w.T @ w @ h + eps)
|
|
188
|
+
w *= (m @ h.T) / (w @ h @ h.T + eps)
|
|
189
|
+
err = float(np.linalg.norm(m - w @ h))
|
|
190
|
+
errors.append(err)
|
|
191
|
+
if abs(previous - err) <= tol * max(1.0, err):
|
|
192
|
+
break
|
|
193
|
+
previous = err
|
|
194
|
+
return w, h, np.asarray(errors)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cosine_similarity(a: ArrayLike, b: ArrayLike) -> float:
|
|
198
|
+
"""Cosine of the angle between two vectors of the same length, in [-1, 1]."""
|
|
199
|
+
va = as_vector(a, name="a")
|
|
200
|
+
vb = as_vector(b, name="b", length=va.shape[0])
|
|
201
|
+
na, nb = np.linalg.norm(va), np.linalg.norm(vb)
|
|
202
|
+
if na == 0.0 or nb == 0.0:
|
|
203
|
+
raise ValueError("cosine similarity is undefined for a zero vector")
|
|
204
|
+
return float(va @ vb / (na * nb))
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Model M t = p: per-process weights recovered from a matrix of process memory profiles.
|
|
2
|
+
|
|
3
|
+
Rows of ``M`` are timestamps, columns are processes. ``p`` is the measured total
|
|
4
|
+
memory at each timestamp and ``t`` is the vector of per-process weights.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy.typing import ArrayLike
|
|
14
|
+
|
|
15
|
+
from pochep_procmat.core import (
|
|
16
|
+
FloatArray,
|
|
17
|
+
_check_pair,
|
|
18
|
+
as_matrix,
|
|
19
|
+
effective_rank,
|
|
20
|
+
nmf,
|
|
21
|
+
solve_nnls,
|
|
22
|
+
solve_normal_cholesky,
|
|
23
|
+
solve_qr,
|
|
24
|
+
solve_svd,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
METHODS = ("qr", "svd", "cholesky", "nnls", "nmf")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class FitResult:
|
|
32
|
+
"""Outcome of one fit: weights, residual and cost of the chosen method."""
|
|
33
|
+
|
|
34
|
+
method: str
|
|
35
|
+
weights: FloatArray
|
|
36
|
+
residual_norm: float
|
|
37
|
+
relative_residual: float
|
|
38
|
+
rank: int
|
|
39
|
+
seconds: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ProcessMemoryModel:
|
|
43
|
+
"""Linear model ``M t = p`` solved by one of several decompositions.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
method:
|
|
48
|
+
One of ``"qr"``, ``"svd"``, ``"cholesky"``, ``"nnls"`` or ``"nmf"``.
|
|
49
|
+
rcond:
|
|
50
|
+
Relative cut-off for singular values in the ``"svd"`` method.
|
|
51
|
+
ridge:
|
|
52
|
+
Tikhonov term used by the ``"cholesky"`` method.
|
|
53
|
+
nmf_rank:
|
|
54
|
+
Number of latent components for the ``"nmf"`` method. The weights are then
|
|
55
|
+
obtained from the non-negative coefficients of the rank-``nmf_rank`` model.
|
|
56
|
+
seed:
|
|
57
|
+
Seed for the random initialisation of NMF.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
method: str = "svd",
|
|
63
|
+
*,
|
|
64
|
+
rcond: float = 1e-10,
|
|
65
|
+
ridge: float = 0.0,
|
|
66
|
+
nmf_rank: int | None = None,
|
|
67
|
+
seed: int = 0,
|
|
68
|
+
) -> None:
|
|
69
|
+
if method not in METHODS:
|
|
70
|
+
raise ValueError(f"method must be one of {METHODS}, got {method!r}")
|
|
71
|
+
if rcond < 0:
|
|
72
|
+
raise ValueError("rcond must be non-negative")
|
|
73
|
+
if ridge < 0:
|
|
74
|
+
raise ValueError("ridge must be non-negative")
|
|
75
|
+
self.method = method
|
|
76
|
+
self.rcond = rcond
|
|
77
|
+
self.ridge = ridge
|
|
78
|
+
self.nmf_rank = nmf_rank
|
|
79
|
+
self.seed = seed
|
|
80
|
+
self._weights: FloatArray | None = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def weights(self) -> FloatArray:
|
|
84
|
+
"""Weights found by the last ``fit``. Raises ``RuntimeError`` before a fit."""
|
|
85
|
+
if self._weights is None:
|
|
86
|
+
raise RuntimeError("the model is not fitted yet")
|
|
87
|
+
return self._weights
|
|
88
|
+
|
|
89
|
+
def fit(self, M: ArrayLike, p: ArrayLike) -> FitResult:
|
|
90
|
+
"""Estimate ``t`` from ``M`` and ``p`` and return a :class:`FitResult`."""
|
|
91
|
+
m, vec = _check_pair(M, p)
|
|
92
|
+
start = time.perf_counter()
|
|
93
|
+
if self.method == "qr":
|
|
94
|
+
t = solve_qr(m, vec)
|
|
95
|
+
elif self.method == "svd":
|
|
96
|
+
t = solve_svd(m, vec, rcond=self.rcond)
|
|
97
|
+
elif self.method == "cholesky":
|
|
98
|
+
t = solve_normal_cholesky(m, vec, ridge=self.ridge)
|
|
99
|
+
elif self.method == "nnls":
|
|
100
|
+
t = solve_nnls(m, vec)
|
|
101
|
+
else:
|
|
102
|
+
t = self._fit_nmf(m, vec)
|
|
103
|
+
seconds = time.perf_counter() - start
|
|
104
|
+
self._weights = t
|
|
105
|
+
residual = float(np.linalg.norm(m @ t - vec))
|
|
106
|
+
norm_p = float(np.linalg.norm(vec))
|
|
107
|
+
relative = residual / norm_p if norm_p > 0 else residual
|
|
108
|
+
return FitResult(
|
|
109
|
+
method=self.method,
|
|
110
|
+
weights=t,
|
|
111
|
+
residual_norm=residual,
|
|
112
|
+
relative_residual=relative,
|
|
113
|
+
rank=effective_rank(m),
|
|
114
|
+
seconds=seconds,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _fit_nmf(self, m: FloatArray, vec: FloatArray) -> FloatArray:
|
|
118
|
+
if np.any(m < 0):
|
|
119
|
+
raise ValueError("the nmf method needs a non-negative matrix")
|
|
120
|
+
if np.any(vec < 0):
|
|
121
|
+
raise ValueError("the nmf method needs a non-negative vector p")
|
|
122
|
+
rank = self.nmf_rank if self.nmf_rank is not None else min(m.shape)
|
|
123
|
+
w, h, _ = nmf(m, rank, seed=self.seed)
|
|
124
|
+
# The weights are fitted against the rank-limited approximation W H, and the
|
|
125
|
+
# non-negativity of t keeps them interpretable as process shares.
|
|
126
|
+
return solve_nnls(w @ h, vec)
|
|
127
|
+
|
|
128
|
+
def predict(self, M: ArrayLike) -> FloatArray:
|
|
129
|
+
"""Return ``M @ t`` for the weights found by the last fit."""
|
|
130
|
+
t = self.weights
|
|
131
|
+
m = as_matrix(M)
|
|
132
|
+
if m.shape[1] != t.shape[0]:
|
|
133
|
+
raise ValueError(f"M must have {t.shape[0]} columns, got {m.shape[1]}")
|
|
134
|
+
return m @ t
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Synthetic process memory profiles for tests and the demo notebook.
|
|
2
|
+
|
|
3
|
+
The generator produces ``r`` non-negative profiles over ``T`` timestamps. Each profile
|
|
4
|
+
is a block of memory that an application holds while it is running and releases after
|
|
5
|
+
it stops, plus a small background noise. Nothing here models a real operating system;
|
|
6
|
+
the output is synthetic by construction.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_process_profiles(
|
|
16
|
+
n_times: int,
|
|
17
|
+
n_processes: int,
|
|
18
|
+
*,
|
|
19
|
+
seed: int = 0,
|
|
20
|
+
noise: float = 0.02,
|
|
21
|
+
mean_block_mb: float = 150.0,
|
|
22
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
|
|
23
|
+
"""Return ``(M, t_true, p)`` with ``p = M @ t_true + noise``.
|
|
24
|
+
|
|
25
|
+
``M`` has shape (n_times, n_processes): column ``j`` is the memory profile of process ``j``
|
|
26
|
+
in megabytes. ``t_true`` holds the true per-process weights, close to 1, and ``p`` is the
|
|
27
|
+
observed total memory with additive Gaussian noise of relative size ``noise``.
|
|
28
|
+
"""
|
|
29
|
+
if n_times < 2 or n_processes < 1:
|
|
30
|
+
raise ValueError("need at least 2 timestamps and 1 process")
|
|
31
|
+
if noise < 0:
|
|
32
|
+
raise ValueError("noise must be non-negative")
|
|
33
|
+
if mean_block_mb <= 0:
|
|
34
|
+
raise ValueError("mean_block_mb must be positive")
|
|
35
|
+
rng = np.random.default_rng(seed)
|
|
36
|
+
profiles = np.zeros((n_times, n_processes))
|
|
37
|
+
for j in range(n_processes):
|
|
38
|
+
n_blocks = rng.integers(1, 4)
|
|
39
|
+
for _ in range(n_blocks):
|
|
40
|
+
start = rng.integers(0, n_times - 1)
|
|
41
|
+
length = rng.integers(1, max(2, n_times // 3))
|
|
42
|
+
stop = min(n_times, start + length)
|
|
43
|
+
height = rng.uniform(0.2, 1.8) * mean_block_mb
|
|
44
|
+
profiles[start:stop, j] += height
|
|
45
|
+
t_true = rng.uniform(0.9, 1.1, size=n_processes)
|
|
46
|
+
clean = profiles @ t_true
|
|
47
|
+
scale = np.linalg.norm(clean) / np.sqrt(n_times) if n_times else 1.0
|
|
48
|
+
p = clean + noise * scale * rng.standard_normal(n_times)
|
|
49
|
+
return profiles, t_true, p
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from pochep_procmat import (
|
|
5
|
+
as_matrix,
|
|
6
|
+
as_vector,
|
|
7
|
+
cosine_similarity,
|
|
8
|
+
effective_rank,
|
|
9
|
+
nmf,
|
|
10
|
+
qr_decompose,
|
|
11
|
+
solve_nnls,
|
|
12
|
+
solve_normal_cholesky,
|
|
13
|
+
solve_qr,
|
|
14
|
+
solve_svd,
|
|
15
|
+
svd_decompose,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def full_rank():
|
|
21
|
+
rng = np.random.default_rng(1)
|
|
22
|
+
m = rng.uniform(0.0, 1.0, size=(40, 5))
|
|
23
|
+
t = rng.uniform(0.5, 1.5, size=5)
|
|
24
|
+
return m, t, m @ t
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_qr_reconstructs_matrix(full_rank):
|
|
28
|
+
m, _, _ = full_rank
|
|
29
|
+
q, r = qr_decompose(m)
|
|
30
|
+
assert q.shape == (40, 5) and r.shape == (5, 5)
|
|
31
|
+
assert np.allclose(q @ r, m)
|
|
32
|
+
assert np.allclose(q.T @ q, np.eye(5))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_svd_reconstructs_and_sorts(full_rank):
|
|
36
|
+
m, _, _ = full_rank
|
|
37
|
+
u, s, vt = svd_decompose(m)
|
|
38
|
+
assert np.allclose(u @ np.diag(s) @ vt, m)
|
|
39
|
+
assert np.all(np.diff(s) <= 0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_solvers_recover_exact_weights(full_rank):
|
|
43
|
+
m, t, p = full_rank
|
|
44
|
+
for solved in (solve_qr(m, p), solve_svd(m, p), solve_normal_cholesky(m, p), solve_nnls(m, p)):
|
|
45
|
+
assert np.allclose(solved, t, atol=1e-6)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_svd_matches_lstsq_on_rank_deficient():
|
|
49
|
+
m = np.array([[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [0.0, 1.0, 1.0], [1.0, 0.0, 1.0]])
|
|
50
|
+
p = np.array([1.0, 2.0, 0.5, 1.5])
|
|
51
|
+
expected = np.linalg.lstsq(m, p, rcond=None)[0]
|
|
52
|
+
assert np.allclose(solve_svd(m, p), expected, atol=1e-8)
|
|
53
|
+
assert effective_rank(m) == 2
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_qr_rejects_rank_deficient():
|
|
57
|
+
m = np.array([[1.0, 2.0], [2.0, 4.0], [3.0, 6.0]])
|
|
58
|
+
with pytest.raises(ValueError, match="full column rank"):
|
|
59
|
+
solve_qr(m, np.ones(3))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_cholesky_needs_ridge_when_singular():
|
|
63
|
+
m = np.array([[1.0, 1.0], [1.0, 1.0], [1.0, 1.0]])
|
|
64
|
+
with pytest.raises(ValueError, match="positive definite"):
|
|
65
|
+
solve_normal_cholesky(m, np.ones(3))
|
|
66
|
+
t = solve_normal_cholesky(m, np.ones(3), ridge=1e-6)
|
|
67
|
+
assert np.allclose(m @ t, np.ones(3), atol=1e-3)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_nnls_respects_non_negativity():
|
|
71
|
+
m = np.array([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]])
|
|
72
|
+
p = np.array([2.0, -1.0, 0.5])
|
|
73
|
+
t = solve_nnls(m, p)
|
|
74
|
+
assert np.all(t >= 0)
|
|
75
|
+
assert t[1] == pytest.approx(0.0, abs=1e-8)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_nmf_is_non_negative_and_error_drops():
|
|
79
|
+
rng = np.random.default_rng(2)
|
|
80
|
+
m = rng.uniform(0.0, 1.0, size=(30, 6))
|
|
81
|
+
w, h, errors = nmf(m, 3, max_iter=300, seed=4)
|
|
82
|
+
assert np.all(w >= 0) and np.all(h >= 0)
|
|
83
|
+
assert w.shape == (30, 3) and h.shape == (3, 6)
|
|
84
|
+
assert errors[-1] <= errors[0]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_nmf_exact_for_low_rank_input():
|
|
88
|
+
rng = np.random.default_rng(3)
|
|
89
|
+
base = rng.uniform(0.1, 1.0, size=(20, 2)) @ rng.uniform(0.1, 1.0, size=(2, 5))
|
|
90
|
+
w, h, errors = nmf(base, 2, max_iter=3000, tol=1e-10, seed=0)
|
|
91
|
+
assert np.linalg.norm(base - w @ h) / np.linalg.norm(base) < 1e-2
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize(
|
|
95
|
+
"bad",
|
|
96
|
+
[np.zeros((0, 3)), np.ones(5), [[1.0, np.nan]], [["a", "b"]]],
|
|
97
|
+
)
|
|
98
|
+
def test_as_matrix_rejects_bad_input(bad):
|
|
99
|
+
with pytest.raises((TypeError, ValueError)):
|
|
100
|
+
as_matrix(bad)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_as_vector_checks_length():
|
|
104
|
+
with pytest.raises(ValueError, match="length 3"):
|
|
105
|
+
as_vector([1.0, 2.0], length=3)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_nmf_rejects_negative_and_bad_rank():
|
|
109
|
+
with pytest.raises(ValueError):
|
|
110
|
+
nmf(np.array([[1.0, -1.0]]), 1)
|
|
111
|
+
with pytest.raises(ValueError):
|
|
112
|
+
nmf(np.ones((3, 3)), 4)
|
|
113
|
+
with pytest.raises(ValueError):
|
|
114
|
+
nmf(np.ones((3, 3)), 0)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_cosine_similarity_bounds():
|
|
118
|
+
assert cosine_similarity([1.0, 0.0], [2.0, 0.0]) == pytest.approx(1.0)
|
|
119
|
+
assert cosine_similarity([1.0, 0.0], [0.0, 3.0]) == pytest.approx(0.0)
|
|
120
|
+
with pytest.raises(ValueError):
|
|
121
|
+
cosine_similarity([0.0, 0.0], [1.0, 1.0])
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from pochep_procmat import METHODS, ProcessMemoryModel, make_process_profiles
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_all_methods_fit_synthetic_data():
|
|
8
|
+
m, t_true, p = make_process_profiles(120, 4, seed=5, noise=0.0)
|
|
9
|
+
for method in METHODS:
|
|
10
|
+
model = ProcessMemoryModel(method, nmf_rank=4)
|
|
11
|
+
res = model.fit(m, p)
|
|
12
|
+
assert res.method == method
|
|
13
|
+
assert res.weights.shape == (4,)
|
|
14
|
+
assert res.relative_residual < 0.2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_svd_and_qr_agree_on_full_rank():
|
|
18
|
+
m, _, p = make_process_profiles(80, 3, seed=7, noise=0.01)
|
|
19
|
+
a = ProcessMemoryModel("svd").fit(m, p).weights
|
|
20
|
+
b = ProcessMemoryModel("qr").fit(m, p).weights
|
|
21
|
+
assert np.allclose(a, b, atol=1e-8)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_predict_uses_fitted_weights():
|
|
25
|
+
m, _, p = make_process_profiles(60, 3, seed=9, noise=0.0)
|
|
26
|
+
model = ProcessMemoryModel("svd")
|
|
27
|
+
model.fit(m, p)
|
|
28
|
+
assert np.allclose(model.predict(m), m @ model.weights)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_predict_before_fit_raises():
|
|
32
|
+
with pytest.raises(RuntimeError):
|
|
33
|
+
ProcessMemoryModel().predict(np.ones((3, 2)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_bad_method_and_parameters():
|
|
37
|
+
with pytest.raises(ValueError):
|
|
38
|
+
ProcessMemoryModel("lu")
|
|
39
|
+
with pytest.raises(ValueError):
|
|
40
|
+
ProcessMemoryModel("svd", rcond=-1.0)
|
|
41
|
+
with pytest.raises(ValueError):
|
|
42
|
+
ProcessMemoryModel("cholesky", ridge=-1.0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_fit_rejects_length_mismatch():
|
|
46
|
+
with pytest.raises(ValueError):
|
|
47
|
+
ProcessMemoryModel("svd").fit(np.ones((5, 2)), np.ones(4))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_synthetic_generator_validates_input():
|
|
51
|
+
with pytest.raises(ValueError):
|
|
52
|
+
make_process_profiles(1, 3)
|
|
53
|
+
with pytest.raises(ValueError):
|
|
54
|
+
make_process_profiles(10, 3, noise=-0.1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_synthetic_generator_is_reproducible():
|
|
58
|
+
a = make_process_profiles(50, 3, seed=11)
|
|
59
|
+
b = make_process_profiles(50, 3, seed=11)
|
|
60
|
+
assert all(np.array_equal(x, y) for x, y in zip(a, b, strict=True))
|