nugap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nugap/__init__.py +29 -0
- nugap/fitting.py +267 -0
- nugap/metric.py +179 -0
- nugap/network.py +222 -0
- nugap/pipeline.py +106 -0
- nugap/replicates.py +231 -0
- nugap/systems.py +132 -0
- nugap/viz.py +183 -0
- nugap-0.1.0.dist-info/METADATA +262 -0
- nugap-0.1.0.dist-info/RECORD +13 -0
- nugap-0.1.0.dist-info/WHEEL +5 -0
- nugap-0.1.0.dist-info/licenses/LICENSE +21 -0
- nugap-0.1.0.dist-info/top_level.txt +1 -0
nugap/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""nugap: the Vinnicombe nu-gap metric and a pipeline for comparing
|
|
2
|
+
time-course data across two conditions.
|
|
3
|
+
|
|
4
|
+
Quick start
|
|
5
|
+
-----------
|
|
6
|
+
from nugap import tf, nu_gap
|
|
7
|
+
d = nu_gap(tf([1],[1,1]), tf([1],[1,1.2])) # ~0.07
|
|
8
|
+
|
|
9
|
+
from nugap import compare_conditions
|
|
10
|
+
df = compare_conditions(data_A, data_B, t) # ranked table of changes
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .systems import LTI, tf, from_zpk, from_control, to_continuous
|
|
14
|
+
from .metric import nu_gap, chordal_distance, winding_condition
|
|
15
|
+
from .fitting import fit_model, fit_prony, fit_arx, fit_arx_fast, fit_first_order, FitResult
|
|
16
|
+
from .pipeline import compare_conditions, compare_variable
|
|
17
|
+
from .replicates import compare_conditions_replicates, compare_variable_replicates
|
|
18
|
+
from .network import compare_network
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"LTI", "tf", "from_zpk", "from_control", "to_continuous",
|
|
22
|
+
"nu_gap", "chordal_distance", "winding_condition",
|
|
23
|
+
"fit_model", "fit_prony", "fit_arx", "fit_arx_fast", "fit_first_order", "FitResult",
|
|
24
|
+
"compare_conditions", "compare_variable",
|
|
25
|
+
"compare_conditions_replicates", "compare_variable_replicates",
|
|
26
|
+
"compare_network",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
nugap/fitting.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Fit discrete-time LTI models to time-course data.
|
|
2
|
+
|
|
3
|
+
This is the part of the pipeline that turns a measured trajectory into an
|
|
4
|
+
``LTI`` system that the nu-gap metric can compare. It is deliberately
|
|
5
|
+
separated from the metric so you can swap in your own identification routine
|
|
6
|
+
(e.g. to mirror exactly what MATLAB's System Identification Toolbox did).
|
|
7
|
+
|
|
8
|
+
Two cases are handled:
|
|
9
|
+
|
|
10
|
+
* **Input/output data** (you applied a known stimulus ``u`` and recorded the
|
|
11
|
+
response ``y``): an ARX model is fitted by linear least squares, giving a
|
|
12
|
+
discrete transfer function B(z)/A(z).
|
|
13
|
+
|
|
14
|
+
* **Output-only data** (you only have the response ``y``, e.g. a signal that
|
|
15
|
+
rises and relaxes after a perturbation): Prony's method fits ``y`` as the
|
|
16
|
+
impulse response of a discrete LTI system, recovering its poles (modes) and
|
|
17
|
+
a numerator.
|
|
18
|
+
|
|
19
|
+
Sampled data -> discrete-time models is the natural and numerically clean
|
|
20
|
+
choice; the nu-gap metric evaluates these on the unit circle.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from .systems import LTI
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class FitResult:
|
|
33
|
+
model: LTI
|
|
34
|
+
order: int
|
|
35
|
+
r2: float # fit quality of the simulated/predicted output
|
|
36
|
+
method: str
|
|
37
|
+
sample_time: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _sample_time(t) -> float:
|
|
41
|
+
t = np.asarray(t, dtype=float)
|
|
42
|
+
dts = np.diff(t)
|
|
43
|
+
dt = float(np.median(dts))
|
|
44
|
+
if dt <= 0:
|
|
45
|
+
raise ValueError("time vector must be increasing")
|
|
46
|
+
return dt
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _simulate(num, den, u, y0len):
|
|
50
|
+
"""Simulate a discrete tf B/A driven by u (impulse if u is None)."""
|
|
51
|
+
import warnings
|
|
52
|
+
from scipy.signal import dlsim, TransferFunction, BadCoefficients
|
|
53
|
+
|
|
54
|
+
if u is None:
|
|
55
|
+
u = np.zeros(y0len)
|
|
56
|
+
u[0] = 1.0
|
|
57
|
+
with warnings.catch_warnings():
|
|
58
|
+
warnings.simplefilter("ignore", BadCoefficients)
|
|
59
|
+
sysd = TransferFunction(num, den, dt=1.0)
|
|
60
|
+
_, yout = dlsim(sysd, u)
|
|
61
|
+
return np.asarray(yout).ravel()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _r2(y, yhat):
|
|
65
|
+
y = np.asarray(y, dtype=float)
|
|
66
|
+
n = min(len(y), len(yhat))
|
|
67
|
+
y, yhat = y[:n], yhat[:n]
|
|
68
|
+
ss_res = np.sum((y - yhat) ** 2)
|
|
69
|
+
ss_tot = np.sum((y - np.mean(y)) ** 2)
|
|
70
|
+
return float(1.0 - ss_res / ss_tot) if ss_tot > 0 else 0.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fit_prony(t, y, order: int):
|
|
74
|
+
"""Fit y as the impulse response of a discrete LTI of given order.
|
|
75
|
+
|
|
76
|
+
Returns (num, den) discrete polynomial coefficients (highest power first).
|
|
77
|
+
"""
|
|
78
|
+
y = np.asarray(y, dtype=float).ravel()
|
|
79
|
+
N = len(y)
|
|
80
|
+
na = order
|
|
81
|
+
if N <= 2 * na:
|
|
82
|
+
raise ValueError("not enough samples for the requested order")
|
|
83
|
+
|
|
84
|
+
# Linear prediction: y[k] = -sum_{j=1..na} a_j y[k-j]
|
|
85
|
+
rows = []
|
|
86
|
+
rhs = []
|
|
87
|
+
for k in range(na, N):
|
|
88
|
+
rows.append([-y[k - j] for j in range(1, na + 1)])
|
|
89
|
+
rhs.append(y[k])
|
|
90
|
+
A = np.asarray(rows)
|
|
91
|
+
b = np.asarray(rhs)
|
|
92
|
+
a, *_ = np.linalg.lstsq(A, b, rcond=None)
|
|
93
|
+
den = np.concatenate([[1.0], a]) # [1, a1, ..., a_na]
|
|
94
|
+
|
|
95
|
+
# Numerator from the convolution relation treating y as impulse response.
|
|
96
|
+
nb = na
|
|
97
|
+
num = np.zeros(nb + 1)
|
|
98
|
+
for k in range(nb + 1):
|
|
99
|
+
s = 0.0
|
|
100
|
+
for j in range(k + 1):
|
|
101
|
+
if j < len(den):
|
|
102
|
+
s += den[j] * y[k - j]
|
|
103
|
+
num[k] = s
|
|
104
|
+
return num, den
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fit_arx(t, y, u, na: int, nb: int | None = None, nk: int = 1):
|
|
108
|
+
"""Fit an ARX model A(z) y = B(z) u by least squares.
|
|
109
|
+
|
|
110
|
+
Returns (num, den) of the discrete transfer function B(z)/A(z).
|
|
111
|
+
"""
|
|
112
|
+
y = np.asarray(y, dtype=float).ravel()
|
|
113
|
+
u = np.asarray(u, dtype=float).ravel()
|
|
114
|
+
N = len(y)
|
|
115
|
+
if nb is None:
|
|
116
|
+
nb = na
|
|
117
|
+
start = max(na, nb + nk)
|
|
118
|
+
rows, rhs = [], []
|
|
119
|
+
for k in range(start, N):
|
|
120
|
+
row = [-y[k - j] for j in range(1, na + 1)]
|
|
121
|
+
row += [u[k - nk - i] for i in range(0, nb + 1)]
|
|
122
|
+
rows.append(row)
|
|
123
|
+
rhs.append(y[k])
|
|
124
|
+
M = np.asarray(rows)
|
|
125
|
+
b = np.asarray(rhs)
|
|
126
|
+
theta, *_ = np.linalg.lstsq(M, b, rcond=None)
|
|
127
|
+
a = theta[:na]
|
|
128
|
+
bb = theta[na:]
|
|
129
|
+
den = np.concatenate([[1.0], a])
|
|
130
|
+
num = np.concatenate([np.zeros(nk), bb]) # account for input delay
|
|
131
|
+
return num, den
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cap_orders(orders, N: int):
|
|
135
|
+
"""Limit candidate orders so we never overfit short trajectories.
|
|
136
|
+
|
|
137
|
+
A model of order p has ~2p+1 free parameters; with N samples we keep
|
|
138
|
+
roughly 5 samples per parameter, so p_max ~ N//5 (at least 1).
|
|
139
|
+
"""
|
|
140
|
+
p_max = max(1, N // 5)
|
|
141
|
+
capped = [o for o in orders if o <= p_max]
|
|
142
|
+
return capped or [1]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def fit_arx_fast(t, y, u, na: int, nb: int = 0, nk: int = 1, dt=None):
|
|
146
|
+
"""Fast ARX fit of arbitrary order with a recursive simulation R^2.
|
|
147
|
+
|
|
148
|
+
Fits A(z) y = B(z) u with ``na`` poles, ``nb+1`` numerator taps and input
|
|
149
|
+
delay ``nk`` by linear least squares, then simulates the model from u
|
|
150
|
+
(warm-started on the first samples) to score the fit. Returns (LTI, r2).
|
|
151
|
+
|
|
152
|
+
Designed to be called millions of times for pairwise identification, so it
|
|
153
|
+
avoids scipy per call. ``na=1, nb=0`` reproduces the first-order model
|
|
154
|
+
K/(tau s + 1) (MATLAB ``tfest(data, 1, 0)``); ``na=2, nb=0`` is the
|
|
155
|
+
canonical two-pole, no-zero second-order system.
|
|
156
|
+
"""
|
|
157
|
+
y = np.asarray(y, dtype=float).ravel()
|
|
158
|
+
u = np.asarray(u, dtype=float).ravel()
|
|
159
|
+
if dt is None:
|
|
160
|
+
dt = _sample_time(t)
|
|
161
|
+
na, nb, nk = int(na), int(nb), int(nk)
|
|
162
|
+
N = len(y)
|
|
163
|
+
start = max(na, nb + nk)
|
|
164
|
+
n_params = na + nb + 1
|
|
165
|
+
if N - start < n_params:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"not enough samples ({N}) for order na={na}, nb={nb}")
|
|
168
|
+
|
|
169
|
+
rows, rhs = [], []
|
|
170
|
+
for k in range(start, N):
|
|
171
|
+
row = [-y[k - i] for i in range(1, na + 1)]
|
|
172
|
+
row += [u[k - nk - i] for i in range(0, nb + 1)]
|
|
173
|
+
rows.append(row)
|
|
174
|
+
rhs.append(y[k])
|
|
175
|
+
theta, *_ = np.linalg.lstsq(np.asarray(rows), np.asarray(rhs), rcond=None)
|
|
176
|
+
a = theta[:na]
|
|
177
|
+
b = theta[na:]
|
|
178
|
+
|
|
179
|
+
den = np.concatenate([[1.0], a])
|
|
180
|
+
num = np.concatenate([np.zeros(nk), b]) # input delay
|
|
181
|
+
|
|
182
|
+
# simulate the model from u, warm-started with measured y on [0, start)
|
|
183
|
+
yhat = np.empty(N)
|
|
184
|
+
yhat[:start] = y[:start]
|
|
185
|
+
for k in range(start, N):
|
|
186
|
+
val = 0.0
|
|
187
|
+
for i in range(1, na + 1):
|
|
188
|
+
val -= a[i - 1] * yhat[k - i]
|
|
189
|
+
for i in range(0, nb + 1):
|
|
190
|
+
val += b[i] * u[k - nk - i]
|
|
191
|
+
yhat[k] = val
|
|
192
|
+
|
|
193
|
+
ys, yh = y[start:], yhat[start:]
|
|
194
|
+
ss_tot = np.sum((ys - np.mean(ys)) ** 2)
|
|
195
|
+
r2 = float(1.0 - np.sum((ys - yh) ** 2) / ss_tot) if ss_tot > 0 else 0.0
|
|
196
|
+
return LTI(num, den, dt=dt), r2
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def fit_first_order(t, y, u, dt=None):
|
|
200
|
+
"""First-order ARX fit (one pole, no zero). Thin wrapper around
|
|
201
|
+
``fit_arx_fast`` with na=1, nb=0, nk=1. Returns (LTI, r2)."""
|
|
202
|
+
return fit_arx_fast(t, y, u, na=1, nb=0, nk=1, dt=dt)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def fit_model(
|
|
206
|
+
t,
|
|
207
|
+
y,
|
|
208
|
+
u=None,
|
|
209
|
+
orders=range(1, 5),
|
|
210
|
+
method: str = "auto",
|
|
211
|
+
) -> FitResult:
|
|
212
|
+
"""Fit a discrete LTI model, selecting the order by AIC.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
t : array time stamps (used only to get the sample time)
|
|
217
|
+
y : array measured response
|
|
218
|
+
u : array or None stimulus, if known
|
|
219
|
+
orders : iterable of int candidate model orders to try. Automatically
|
|
220
|
+
capped to ~N//5 so short trajectories are not overfitted.
|
|
221
|
+
method : 'auto' | 'prony' | 'arx'
|
|
222
|
+
'auto' uses ARX when u is given, Prony otherwise.
|
|
223
|
+
|
|
224
|
+
Returns
|
|
225
|
+
-------
|
|
226
|
+
FitResult
|
|
227
|
+
"""
|
|
228
|
+
y = np.asarray(y, dtype=float).ravel()
|
|
229
|
+
dt = _sample_time(t)
|
|
230
|
+
N = len(y)
|
|
231
|
+
orders = _cap_orders(list(orders), N)
|
|
232
|
+
|
|
233
|
+
if method == "auto":
|
|
234
|
+
method = "arx" if u is not None else "prony"
|
|
235
|
+
|
|
236
|
+
best = None
|
|
237
|
+
for order in orders:
|
|
238
|
+
try:
|
|
239
|
+
if method == "prony":
|
|
240
|
+
num, den = fit_prony(t, y, order)
|
|
241
|
+
yhat = _simulate(num, den, None, N)
|
|
242
|
+
nparams = 2 * order + 1
|
|
243
|
+
elif method == "arx":
|
|
244
|
+
num, den = fit_arx(t, y, u, na=order)
|
|
245
|
+
yhat = _simulate(num, den, np.asarray(u, float).ravel(), N)
|
|
246
|
+
nparams = 2 * order + 1
|
|
247
|
+
else:
|
|
248
|
+
raise ValueError(f"unknown method {method!r}")
|
|
249
|
+
except Exception:
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
n = min(len(y), len(yhat))
|
|
253
|
+
sse = np.sum((y[:n] - yhat[:n]) ** 2)
|
|
254
|
+
if sse <= 0 or not np.isfinite(sse):
|
|
255
|
+
continue
|
|
256
|
+
aic = N * np.log(sse / N) + 2 * nparams
|
|
257
|
+
r2 = _r2(y, yhat)
|
|
258
|
+
cand = (aic, order, num, den, r2)
|
|
259
|
+
if best is None or aic < best[0]:
|
|
260
|
+
best = cand
|
|
261
|
+
|
|
262
|
+
if best is None:
|
|
263
|
+
raise RuntimeError("model fitting failed for all candidate orders")
|
|
264
|
+
|
|
265
|
+
_, order, num, den, r2 = best
|
|
266
|
+
model = LTI(num, den, dt=dt)
|
|
267
|
+
return FitResult(model=model, order=order, r2=r2, method=method, sample_time=dt)
|
nugap/metric.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""The Vinnicombe nu-gap metric for SISO systems.
|
|
2
|
+
|
|
3
|
+
Reference: G. Vinnicombe, "Frequency domain uncertainty and the graph
|
|
4
|
+
topology", IEEE Trans. Automatic Control 38 (1993) 1371-1383, and
|
|
5
|
+
G. Vinnicombe, "Uncertain Systems and Feedback" (2001).
|
|
6
|
+
|
|
7
|
+
For two systems P1, P2 the nu-gap is
|
|
8
|
+
|
|
9
|
+
delta_nu(P1, P2) = sup_w kappa(P1, P2)(w) if the winding-number
|
|
10
|
+
condition holds,
|
|
11
|
+
= 1 otherwise,
|
|
12
|
+
|
|
13
|
+
where the *chordal distance* between two scalar transfer functions at a
|
|
14
|
+
frequency point is
|
|
15
|
+
|
|
16
|
+
|P1 - P2|
|
|
17
|
+
kappa = ------------------------------------ , in [0, 1].
|
|
18
|
+
sqrt(1+|P1|^2) * sqrt(1+|P2|^2)
|
|
19
|
+
|
|
20
|
+
The winding-number condition (SISO) is
|
|
21
|
+
|
|
22
|
+
wno( 1 + conj(P2) * P1 ) + eta(P1) - eta(P2) - eta0(P2) = 0
|
|
23
|
+
|
|
24
|
+
with eta = number of unstable poles and eta0 = number of boundary poles
|
|
25
|
+
(imaginary axis for continuous systems, unit circle for discrete). If the
|
|
26
|
+
condition fails the systems are "topologically far" and delta_nu = 1.
|
|
27
|
+
|
|
28
|
+
The result is symmetric: delta_nu(P1, P2) == delta_nu(P2, P1), and lies in
|
|
29
|
+
[0, 1]. 0 means identical dynamics; near 1 means very different.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import warnings
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
from .systems import LTI
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def chordal_distance(P1: np.ndarray, P2: np.ndarray) -> np.ndarray:
|
|
41
|
+
"""Point-wise chordal distance between two frequency responses."""
|
|
42
|
+
P1 = np.asarray(P1)
|
|
43
|
+
P2 = np.asarray(P2)
|
|
44
|
+
return np.abs(P1 - P2) / (np.sqrt(1.0 + np.abs(P1) ** 2) * np.sqrt(1.0 + np.abs(P2) ** 2))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _contour(sys1: LTI, sys2: LTI, n: int):
|
|
48
|
+
"""Build the integration contour and the matching complex points.
|
|
49
|
+
|
|
50
|
+
Returns (param, points, closed) where ``points`` are the complex
|
|
51
|
+
arguments at which to evaluate the transfer functions.
|
|
52
|
+
|
|
53
|
+
Continuous: a dense, symmetric grid of frequencies covering the system
|
|
54
|
+
dynamics by several decades; points = j*w.
|
|
55
|
+
Discrete: theta in (-pi, pi]; points = exp(j*theta).
|
|
56
|
+
"""
|
|
57
|
+
if sys1.is_discrete() or sys2.is_discrete():
|
|
58
|
+
# unit circle
|
|
59
|
+
theta = np.linspace(-np.pi, np.pi, n, endpoint=True)
|
|
60
|
+
return theta, np.exp(1j * theta), True
|
|
61
|
+
# continuous: choose range from the pole/zero magnitudes
|
|
62
|
+
feats = np.concatenate([
|
|
63
|
+
np.abs(sys1.poles), np.abs(sys2.poles),
|
|
64
|
+
np.abs(sys1.zeros), np.abs(sys2.zeros),
|
|
65
|
+
])
|
|
66
|
+
feats = feats[feats > 0]
|
|
67
|
+
if feats.size:
|
|
68
|
+
lo = np.log10(feats.min()) - 3
|
|
69
|
+
hi = np.log10(feats.max()) + 3
|
|
70
|
+
else:
|
|
71
|
+
lo, hi = -3.0, 3.0
|
|
72
|
+
w_pos = np.logspace(lo, hi, n // 2)
|
|
73
|
+
w = np.concatenate([-w_pos[::-1], w_pos])
|
|
74
|
+
return w, 1j * w, False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _winding_number(g_vals: np.ndarray, closed: bool) -> int:
|
|
78
|
+
"""Net counter-clockwise encirclements of the origin by g along the
|
|
79
|
+
contour, from the sampled values g_vals (ordered along the contour).
|
|
80
|
+
|
|
81
|
+
For the discrete unit circle the contour is already closed. For the
|
|
82
|
+
continuous imaginary axis we sweep a very wide symmetric frequency band;
|
|
83
|
+
because here g(+/-inf) -> a real positive constant, the semicircle at
|
|
84
|
+
infinity adds no net phase, so the imaginary-axis sweep suffices.
|
|
85
|
+
"""
|
|
86
|
+
ang = np.unwrap(np.angle(g_vals))
|
|
87
|
+
total = ang[-1] - ang[0]
|
|
88
|
+
return int(np.round(total / (2.0 * np.pi)))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _g_on_contour(sys1: LTI, sys2: LTI, points: np.ndarray) -> np.ndarray:
|
|
92
|
+
"""g = 1 + conj(P2) * P1 evaluated on the contour.
|
|
93
|
+
|
|
94
|
+
On the imaginary axis / unit circle the para-conjugate P2~ equals the
|
|
95
|
+
complex conjugate of P2, so this is exactly Vinnicombe's g restricted to
|
|
96
|
+
the contour.
|
|
97
|
+
"""
|
|
98
|
+
P1 = sys1.freqresp(points)
|
|
99
|
+
P2 = sys2.freqresp(points)
|
|
100
|
+
return 1.0 + np.conjugate(P2) * P1
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def winding_condition(sys1: LTI, sys2: LTI, n: int = 8192, tol: float = 1e-7):
|
|
104
|
+
"""Evaluate the integer winding-number condition.
|
|
105
|
+
|
|
106
|
+
Returns (ok: bool, value: int, touches_origin: bool).
|
|
107
|
+
"""
|
|
108
|
+
_, points, closed = _contour(sys1, sys2, n)
|
|
109
|
+
g = _g_on_contour(sys1, sys2, points)
|
|
110
|
+
|
|
111
|
+
# If g touches the origin on the contour, the chordal distance reaches 1
|
|
112
|
+
# somewhere and the systems sit on the boundary -> treat as far.
|
|
113
|
+
touches = bool(np.min(np.abs(g)) < 1e-9 * max(1.0, np.max(np.abs(g))))
|
|
114
|
+
|
|
115
|
+
wno_ccw = _winding_number(g, closed)
|
|
116
|
+
# Vinnicombe's condition counts clockwise encirclements of the origin
|
|
117
|
+
# along the Nyquist contour; _winding_number returns the (mathematically
|
|
118
|
+
# standard) counter-clockwise count, so negate.
|
|
119
|
+
wno = -wno_ccw
|
|
120
|
+
eta1, _ = sys1.pole_counts(tol)
|
|
121
|
+
eta2, eta02 = sys2.pole_counts(tol)
|
|
122
|
+
value = wno + eta1 - eta2 - eta02
|
|
123
|
+
ok = (value == 0) and not touches
|
|
124
|
+
return ok, value, touches
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def nu_gap(
|
|
128
|
+
sys1: LTI,
|
|
129
|
+
sys2: LTI,
|
|
130
|
+
n: int = 8192,
|
|
131
|
+
tol: float = 1e-7,
|
|
132
|
+
return_details: bool = False,
|
|
133
|
+
):
|
|
134
|
+
"""Vinnicombe nu-gap metric delta_nu(sys1, sys2) in [0, 1].
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
sys1, sys2 : LTI
|
|
139
|
+
SISO systems. Must both be continuous or both discrete with the
|
|
140
|
+
same sampling time.
|
|
141
|
+
n : int
|
|
142
|
+
Number of contour samples (resolution of the frequency sweep).
|
|
143
|
+
tol : float
|
|
144
|
+
Tolerance used to classify poles as unstable / on the boundary.
|
|
145
|
+
return_details : bool
|
|
146
|
+
If True, also return a dict with the sup-chordal distance, the
|
|
147
|
+
winding condition value, and the frequency of the maximum.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
float (or (float, dict) if return_details)
|
|
152
|
+
"""
|
|
153
|
+
if sys1.is_discrete() != sys2.is_discrete():
|
|
154
|
+
raise ValueError("cannot compare a continuous system with a discrete one")
|
|
155
|
+
if sys1.is_discrete() and sys2.is_discrete() and sys1.dt != sys2.dt:
|
|
156
|
+
warnings.warn("comparing discrete systems with different sample times")
|
|
157
|
+
|
|
158
|
+
param, points, closed = _contour(sys1, sys2, n)
|
|
159
|
+
P1 = sys1.freqresp(points)
|
|
160
|
+
P2 = sys2.freqresp(points)
|
|
161
|
+
|
|
162
|
+
kappa = chordal_distance(P1, P2)
|
|
163
|
+
imax = int(np.nanargmax(kappa))
|
|
164
|
+
sup_kappa = float(kappa[imax])
|
|
165
|
+
|
|
166
|
+
ok, value, touches = winding_condition(sys1, sys2, n=n, tol=tol)
|
|
167
|
+
delta = sup_kappa if ok else 1.0
|
|
168
|
+
|
|
169
|
+
if return_details:
|
|
170
|
+
details = {
|
|
171
|
+
"sup_chordal": sup_kappa,
|
|
172
|
+
"winding_value": value,
|
|
173
|
+
"winding_ok": ok,
|
|
174
|
+
"touches_origin": touches,
|
|
175
|
+
"arg_at_max": float(param[imax]),
|
|
176
|
+
"result": delta,
|
|
177
|
+
}
|
|
178
|
+
return delta, details
|
|
179
|
+
return delta
|
nugap/network.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Pairwise dynamic-network comparison.
|
|
2
|
+
|
|
3
|
+
Workflow: within each condition, treat every variable as a candidate input for
|
|
4
|
+
every other variable and fit a first-order input->output model. Then, for each
|
|
5
|
+
ordered pair (i -> j), use the nu-gap to compare the condition-A model with the
|
|
6
|
+
condition-B model. Edges with a large nu-gap are interactions whose dynamics
|
|
7
|
+
changed between conditions.
|
|
8
|
+
|
|
9
|
+
With replicates, the within-condition nu-gap (replicate vs replicate of the
|
|
10
|
+
same condition) gives a per-edge noise floor; pooled across all edges it forms
|
|
11
|
+
a well-estimated null for FDR-controlled discovery across the millions of
|
|
12
|
+
edges.
|
|
13
|
+
|
|
14
|
+
Scale note: N variables -> N*(N-1) ordered edges. Each fit is a trivial
|
|
15
|
+
2-parameter least squares; the cost is the metric. First-order systems are
|
|
16
|
+
smooth on the unit circle, so a small ``n`` (default 256) is accurate and keeps
|
|
17
|
+
this tractable. For thousands of variables, run edge batches in parallel
|
|
18
|
+
(the per-edge work is independent) and/or prescreen edges.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import itertools
|
|
24
|
+
import numpy as np
|
|
25
|
+
import pandas as pd
|
|
26
|
+
|
|
27
|
+
from .fitting import fit_arx_fast, _sample_time
|
|
28
|
+
from .metric import nu_gap
|
|
29
|
+
from .replicates import _bh_fdr
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _center(arr):
|
|
33
|
+
"""Subtract each replicate's mean (these models assume no offset)."""
|
|
34
|
+
arr = np.asarray(arr, dtype=float)
|
|
35
|
+
if arr.ndim == 1:
|
|
36
|
+
return arr - np.mean(arr)
|
|
37
|
+
return arr - arr.mean(axis=1, keepdims=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _edge_models(reps_in, reps_out, t, dt, na, nb):
|
|
41
|
+
"""Fit a model per replicate for one (input->output) edge."""
|
|
42
|
+
models, r2s = [], []
|
|
43
|
+
for u, y in zip(reps_in, reps_out):
|
|
44
|
+
try:
|
|
45
|
+
m, r2 = fit_arx_fast(t, y, u, na=na, nb=nb, nk=1, dt=dt)
|
|
46
|
+
if not m.is_stable() and np.max(np.abs(m.poles)) > 5:
|
|
47
|
+
continue # discard wildly diverging fits
|
|
48
|
+
models.append(m)
|
|
49
|
+
r2s.append(r2)
|
|
50
|
+
except Exception:
|
|
51
|
+
continue
|
|
52
|
+
return models, r2s
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _gap_pairs(models, n):
|
|
56
|
+
R = len(models)
|
|
57
|
+
g = {}
|
|
58
|
+
for i, j in itertools.combinations(range(R), 2):
|
|
59
|
+
g[(i, j)] = nu_gap(models[i], models[j], n=n)
|
|
60
|
+
return g
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def compare_network(
|
|
64
|
+
data_A: dict,
|
|
65
|
+
data_B: dict,
|
|
66
|
+
t,
|
|
67
|
+
order: int = 1,
|
|
68
|
+
n_zeros: int | None = None,
|
|
69
|
+
n: int = 256,
|
|
70
|
+
center: bool = True,
|
|
71
|
+
min_r2: float | None = 0.5,
|
|
72
|
+
gate: str = "either",
|
|
73
|
+
null_from_reliable_only: bool = True,
|
|
74
|
+
include_pairs=None,
|
|
75
|
+
global_null: bool = True,
|
|
76
|
+
progress: bool = False,
|
|
77
|
+
):
|
|
78
|
+
"""Compare the pairwise interaction network across conditions.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
data_A, data_B : dict[str, array]
|
|
83
|
+
variable name -> trajectories. Either 1D (single record) or 2D
|
|
84
|
+
(n_replicates x n_timepoints). With replicates a per-edge noise floor
|
|
85
|
+
is computed.
|
|
86
|
+
t : array
|
|
87
|
+
Common time vector.
|
|
88
|
+
order : int
|
|
89
|
+
Number of poles of the per-edge model. 1 = first-order K/(tau s + 1)
|
|
90
|
+
(default), 2 = second-order. Needs more time points at higher order.
|
|
91
|
+
n_zeros : int or None
|
|
92
|
+
Number of zeros in the numerator. Default None -> 0 (all-pole model:
|
|
93
|
+
first-order = 1 pole/0 zeros, second-order = 2 poles/0 zeros). Set to 1
|
|
94
|
+
for e.g. a two-pole/one-zero model (MATLAB ``tfest(data, 2, 1)``).
|
|
95
|
+
n : int
|
|
96
|
+
Metric contour resolution. 256 is accurate for low-order systems.
|
|
97
|
+
center : bool
|
|
98
|
+
Subtract each trajectory's mean before fitting (recommended).
|
|
99
|
+
min_r2 : float or None
|
|
100
|
+
Fit-quality gate threshold. Only edges where a real relationship holds
|
|
101
|
+
are tested; None disables gating.
|
|
102
|
+
gate : {'either', 'both', 'mean'}
|
|
103
|
+
How the per-condition fit qualities are combined for the gate:
|
|
104
|
+
* 'either' (default) - keep the edge if the best replicate fit in
|
|
105
|
+
EITHER condition reaches min_r2 (catches relationships that appear
|
|
106
|
+
or disappear between conditions).
|
|
107
|
+
* 'both' - require the best replicate fit in BOTH conditions to reach
|
|
108
|
+
min_r2 (a relationship present in both, whose dynamics may differ).
|
|
109
|
+
* 'mean' - use the mean R^2 over all replicate fits of both
|
|
110
|
+
conditions.
|
|
111
|
+
include_pairs : iterable[(src, tgt)] or None
|
|
112
|
+
Restrict to specific ordered edges (e.g. a prescreened candidate set).
|
|
113
|
+
If None, all ordered pairs i != j are tested.
|
|
114
|
+
global_null : bool
|
|
115
|
+
Pool within-condition nu-gaps across all edges into one null and report
|
|
116
|
+
p_global / q_global (BH-FDR).
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
pandas.DataFrame, one row per edge, sorted by significance (or nu_gap).
|
|
121
|
+
"""
|
|
122
|
+
if gate not in ("either", "both", "mean"):
|
|
123
|
+
raise ValueError("gate must be 'either', 'both', or 'mean'")
|
|
124
|
+
na = int(order)
|
|
125
|
+
nb = 0 if n_zeros is None else int(n_zeros)
|
|
126
|
+
variables = list(data_A.keys())
|
|
127
|
+
dt = _sample_time(t)
|
|
128
|
+
|
|
129
|
+
def as_reps(d):
|
|
130
|
+
out = {}
|
|
131
|
+
for k, v in d.items():
|
|
132
|
+
v = np.asarray(v, dtype=float)
|
|
133
|
+
if v.ndim == 1:
|
|
134
|
+
v = v[None, :]
|
|
135
|
+
out[k] = _center(v) if center else v
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
A = as_reps(data_A)
|
|
139
|
+
B = as_reps(data_B)
|
|
140
|
+
|
|
141
|
+
if include_pairs is None:
|
|
142
|
+
pairs = [(i, j) for i in variables for j in variables if i != j]
|
|
143
|
+
else:
|
|
144
|
+
pairs = list(include_pairs)
|
|
145
|
+
|
|
146
|
+
records = []
|
|
147
|
+
global_within = []
|
|
148
|
+
for e, (src, tgt) in enumerate(pairs):
|
|
149
|
+
if progress and e % 5000 == 0:
|
|
150
|
+
print(f" {e}/{len(pairs)} edges")
|
|
151
|
+
|
|
152
|
+
mA, r2A = _edge_models(A[src], A[tgt], t, dt, na, nb)
|
|
153
|
+
mB, r2B = _edge_models(B[src], B[tgt], t, dt, na, nb)
|
|
154
|
+
if not mA or not mB:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
# Only test an edge where a real relationship exists; otherwise the
|
|
158
|
+
# meaningless, high-variance fits would pollute the null. How the two
|
|
159
|
+
# conditions' fit qualities are combined is controlled by `gate`.
|
|
160
|
+
best_A = max(r2A, default=-np.inf)
|
|
161
|
+
best_B = max(r2B, default=-np.inf)
|
|
162
|
+
if gate == "either":
|
|
163
|
+
gate_stat = max(best_A, best_B)
|
|
164
|
+
elif gate == "both":
|
|
165
|
+
gate_stat = min(best_A, best_B)
|
|
166
|
+
else: # 'mean'
|
|
167
|
+
gate_stat = float(np.mean(r2A + r2B)) if (r2A or r2B) else -np.inf
|
|
168
|
+
if min_r2 is not None and gate_stat < min_r2:
|
|
169
|
+
continue
|
|
170
|
+
max_r2 = max(best_A, best_B)
|
|
171
|
+
|
|
172
|
+
models = mA + mB
|
|
173
|
+
nA = len(mA)
|
|
174
|
+
idxA, idxB = range(nA), range(nA, len(models))
|
|
175
|
+
gp = _gap_pairs(models, n)
|
|
176
|
+
|
|
177
|
+
def get(i, j):
|
|
178
|
+
return gp[(i, j)] if i < j else gp[(j, i)]
|
|
179
|
+
|
|
180
|
+
between = [get(a, b) for a in idxA for b in idxB]
|
|
181
|
+
within_A = [get(a, b) for a, b in itertools.combinations(idxA, 2)]
|
|
182
|
+
within_B = [get(a, b) for a, b in itertools.combinations(idxB, 2)]
|
|
183
|
+
within = within_A + within_B
|
|
184
|
+
|
|
185
|
+
between_med = float(np.median(between))
|
|
186
|
+
within_med = float(np.median(within)) if within else 0.0
|
|
187
|
+
rec = {
|
|
188
|
+
"source": src, "target": tgt,
|
|
189
|
+
"nu_gap": between_med,
|
|
190
|
+
"within_median": within_med,
|
|
191
|
+
"separation": between_med - within_med,
|
|
192
|
+
"n_reps": min(nA, len(mB)),
|
|
193
|
+
"max_r2": float(max_r2),
|
|
194
|
+
"mean_r2": float(np.mean(r2A + r2B)) if (r2A or r2B) else np.nan,
|
|
195
|
+
}
|
|
196
|
+
records.append(rec)
|
|
197
|
+
if global_null:
|
|
198
|
+
# A within-condition gap belongs in the null only if a real
|
|
199
|
+
# relationship exists in that condition; otherwise we would be
|
|
200
|
+
# pooling the variance of fitting noise to noise, which inflates
|
|
201
|
+
# the null and destroys power (e.g. a condition where the gene
|
|
202
|
+
# went flat). Set null_from_reliable_only=False to pool all.
|
|
203
|
+
thr = -np.inf if (min_r2 is None or not null_from_reliable_only) else min_r2
|
|
204
|
+
if best_A >= thr:
|
|
205
|
+
global_within.extend(within_A)
|
|
206
|
+
if best_B >= thr:
|
|
207
|
+
global_within.extend(within_B)
|
|
208
|
+
|
|
209
|
+
df = pd.DataFrame.from_records(records)
|
|
210
|
+
if df.empty:
|
|
211
|
+
return df
|
|
212
|
+
|
|
213
|
+
if global_null and global_within:
|
|
214
|
+
pool = np.sort(np.asarray(global_within, dtype=float))
|
|
215
|
+
m = pool.size
|
|
216
|
+
ge = m - np.searchsorted(pool, df["nu_gap"].values, side="left")
|
|
217
|
+
df["p_global"] = (ge + 1) / (m + 1)
|
|
218
|
+
df["q_global"] = _bh_fdr(df["p_global"].values)
|
|
219
|
+
df = df.sort_values("q_global", ascending=True).reset_index(drop=True)
|
|
220
|
+
else:
|
|
221
|
+
df = df.sort_values("nu_gap", ascending=False).reset_index(drop=True)
|
|
222
|
+
return df
|