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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
@@ -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))