raj-dahya commited on
Commit
8685a5b
·
verified ·
1 Parent(s): d52e6fe

staging > main: refactored entry point for application

Browse files
Files changed (4) hide show
  1. justfile +2 -2
  2. src/__paths__.py +45 -0
  3. src/examples.py +63 -0
  4. src/main.py +0 -461
justfile CHANGED
@@ -103,8 +103,8 @@ build-requirements-dependencies:
103
  # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
104
 
105
  [group("exec")]
106
- run *args:
107
- @{{PYVENV_ON}} && {{PYVENV}} -m src.main {{args}}
108
 
109
  # --------------------------------
110
  # TARGETS: development + tools
 
103
  # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
104
 
105
  [group("exec")]
106
+ run-examples *args:
107
+ @{{PYVENV_ON}} && {{PYVENV}} -m src.examples {{args}}
108
 
109
  # --------------------------------
110
  # TARGETS: development + tools
src/__paths__.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ # ----------------------------------------------------------------
5
+ # IMPORTS
6
+ # ----------------------------------------------------------------
7
+
8
+ from pathlib import Path
9
+
10
+ # ----------------------------------------------------------------
11
+ # EXPORTS
12
+ # ----------------------------------------------------------------
13
+
14
+ __all__ = [
15
+ "get_root_path",
16
+ "get_source_path",
17
+ ]
18
+
19
+ # ----------------------------------------------------------------
20
+ # CONSTANTS
21
+ # ----------------------------------------------------------------
22
+
23
+ _source = Path(__file__).parent.as_posix()
24
+ _root = Path(__file__).parent.parent.as_posix()
25
+
26
+ # ----------------------------------------------------------------
27
+ # METHODS
28
+ # ----------------------------------------------------------------
29
+
30
+
31
+ def get_root_path(*parts: str) -> str:
32
+ return get_path(_root, *parts)
33
+
34
+
35
+ def get_source_path(*parts: str) -> str:
36
+ return get_path(_source, *parts)
37
+
38
+
39
+ # ----------------------------------------------------------------
40
+ # AUXILIARY METHODS
41
+ # ----------------------------------------------------------------
42
+
43
+
44
+ def get_path(root: str, *parts: str) -> str:
45
+ return root if len(parts) == 0 else Path(root, *parts).as_posix()
src/examples.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ - author: Raj Dahya
6
+ - institute: Universität Leipzig
7
+ - department: Fakultät für Mathematik und Informatik
8
+ - created: 2025-12-27
9
+ - updated: 2026-03-29
10
+ - description:
11
+ Example script to numerically verify methods presented in paper
12
+ """
13
+
14
+ # ----------------------------------------------------------------
15
+ # IMPORTS
16
+ # ----------------------------------------------------------------
17
+
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ os.chdir(Path(__file__).parent.parent.as_posix())
23
+ sys.path.insert(0, os.getcwd())
24
+
25
+
26
+ import torch
27
+
28
+ from ._core.logging import *
29
+ from .experiments.choi_cholesky import *
30
+ from .queries._console.prog_examples import *
31
+ from .setup import *
32
+
33
+ # ----------------------------------------------------------------
34
+ # EXECUTION
35
+ # ----------------------------------------------------------------
36
+
37
+ if __name__ == "__main__":
38
+ # set sessions settings
39
+ sys.tracebacklimit = 0
40
+
41
+ # parse cli flags
42
+ args = CliArguments(info=INFO).parse(*sys.argv[1:])
43
+
44
+ # early terminate if only version requested
45
+ if args.mode == "version":
46
+ print(VERSION)
47
+ exit(0)
48
+
49
+ # set up logging
50
+ configure_logging()
51
+
52
+ # optionally set seed
53
+ if args.seed is not None:
54
+ torch.manual_seed(args.seed)
55
+
56
+ # execute main method
57
+ verify_choi_cholesky(
58
+ d1=args.dim1,
59
+ d2=args.dim2,
60
+ num_experiments=args.num,
61
+ map_kind=args.map,
62
+ algorithm_choice=args.algorithm,
63
+ )
src/main.py DELETED
@@ -1,461 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
-
4
- """
5
- - author: Raj Dahya
6
- - institute: Universität Leipzi
7
- - department: Fakultät für Mathematik und Informatik
8
- - created: 2025-12-27
9
- - updated: 2026-03-29
10
- - title: python script for experimental verification
11
- - description:
12
- Verifies the Choi-Cholesky decomposition of a CP/CPTP map numerically,
13
- as presented in the paper _The Choi-Cholesky algorithm for completely positive maps_
14
- (see <https://arxiv.org/abs/2603.19444>).
15
-
16
- Verification occurs by running the experiment multiple times without failure.
17
-
18
- Failure criteria:
19
-
20
- - [critical] algorithm fails to produce positive entries for the diagonal operator (within tolerance)
21
- - in final result L·L^* is not close to Choi matrix
22
-
23
- NOTE: Due to the discontinuous nature of pseudo-inverses (cf Remark 3.3 in §3),
24
- the algorithm can get close to a critical error under low numerical precision (e.g. Float32).
25
- For this reason we use double precision (Float64) numbers.
26
-
27
- NOTE: The option `MAP_TYPE = "CP"` establishes a proper confirmation.
28
- For completeness we have included the option `"CPTP"`,
29
- but this provides a circular test, as the generation of CPTP-maps here assumes results of paper in advance.
30
-
31
- NOTE: use `MODE="BASIC"` to run the algorithm exactly as presented in the paper (cf Algorithm B.1)
32
- and use `MODE="SIMPLIFIED"` to run an equivalent which has been modified for numerical stability.
33
- """
34
-
35
- # ----------------------------------------------------------------
36
- # USER ADJUSTABLE SETTINGS
37
- # ----------------------------------------------------------------
38
-
39
- MAP_TYPE = "CP" # non-circular test
40
- # MAP_TYPE = "CPTP" # NOTE: circular test - see above
41
- NUM_EXPERIMENTS = 100 # number of randomly generated CP-maps on which to verify algorithm
42
- N1 = 10 # dimension of H1
43
- N2 = 20 # dimension of H2
44
-
45
- # choice of algorithm
46
- MODE="BASIC" # as in paper
47
- # MODE="SIMPLIFIED" # algorithm equivalent to original but modified for numerical stability
48
-
49
- # ----------------------------------------------------------------
50
- # IMPORTS
51
- # ----------------------------------------------------------------
52
-
53
- import os
54
- import sys
55
- from pathlib import Path
56
-
57
- os.chdir(Path(__file__).parent.as_posix())
58
- sys.path.insert(0, os.getcwd())
59
-
60
- import logging
61
- import math
62
- from logging import getLogger
63
- from typing import Literal
64
-
65
- import scipy.linalg
66
- import torch
67
- from tabulate import tabulate
68
-
69
- # ----------------------------------------------------------------
70
- # SETTINGS, CONSTANTS
71
- # ----------------------------------------------------------------
72
-
73
- TOL = 1e-6
74
- # PRECISION = torch.float32 # works but to lower precision
75
- PRECISION = torch.float64
76
- # PRECISION = torch.double # same as f64
77
-
78
- # ----------------------------------------------------------------
79
- # METHODS
80
- # ----------------------------------------------------------------
81
-
82
-
83
- def main(
84
- *,
85
- map_type: Literal["CP", "CPTP"] = "CP",
86
- num_experiments: int = 1,
87
- mode: Literal["BASIC", "SIMPLIFIED"] = "BASIC",
88
- ):
89
- diffs: list[float] = []
90
- for index in range(1, 1 + num_experiments):
91
- logging.info(f"run experiment {index}")
92
-
93
- """
94
- Create a random CP/CPTP-map
95
- """
96
- match map_type:
97
- case "CPTP":
98
- logging.info("generate Choi matrix C of a random CPTP map Φ")
99
- C = random_cptp_choicholesky(N1, N2, precision=PRECISION)
100
- diff = torch.norm(trace_2(C) - torch.eye(N1)).item()
101
- logging.info(f"generated positive C with ‖tr₂(C) – I‖ = {diff:.4g}")
102
-
103
- case _:
104
- logging.info("generate Choi matrix C of a random CP map Φ")
105
- C = random_cp(N1, N2, precision=PRECISION)
106
-
107
- """
108
- Perform Choi-Cholesky decomposition
109
- """
110
- logging.info("compute Choi-Cholesky decomposition for Φ")
111
- match mode:
112
- case "SIMPLIFIED":
113
- # method equivalent to algorithm in paper, modified for numerical stability
114
- L = algorithm_bipartite_cholesky_modified(C)
115
-
116
- # case "BASIC":
117
- case _:
118
- # method as in paper
119
- L = algorithm_bipartite_cholesky(C)
120
-
121
- """
122
- Verify validity of Choi-Cholesky decomposition
123
- """
124
- C_recovered = tensor_multiply(L, tensor_conj(L))
125
- diff = tensor_diff(C, C_recovered, rel=True)
126
- diffs.append(diff)
127
-
128
- """
129
- Perform statistical evaluation of success rate
130
- """
131
-
132
- diffs = torch.asarray(diffs)
133
- levels = [0.25, 0.5, 0.75, 0.975]
134
- quantiles = torch.quantile(diffs, q=torch.asarray(levels)).tolist()
135
- table = tabulate(
136
- zip(levels, quantiles),
137
- headers=("quantile", "Diff"),
138
- tablefmt="rst",
139
- colalign=("right", "right"),
140
- floatfmt=(".2%", ".3e"),
141
- )
142
- logging.info(f"quantiles for rel. differences ‖L·L^* – C(Φ)‖/‖C(Φ)‖:\n{table}")
143
- diff = diffs[-1] # get 97.5% quantile of rel differences
144
- assert diff < 0.5*TOL, \
145
- f"Algorithm failed to produce decomposition that recovers original Choi matrix within tolerance {TOL:.4g}." # fmt: skip
146
- logging.info(f"Algorithm succeeded in producing decomposition that recovers original Choi matrix within tolerance {TOL:.4g}.") # fmt: skip
147
- pass
148
-
149
-
150
- # ----------------------------------------------------------------
151
- # ALGORITHMS
152
- # ----------------------------------------------------------------
153
-
154
-
155
- def algorithm_bipartite_cholesky(
156
- C: torch.Tensor,
157
- /,
158
- ) -> tuple[
159
- torch.Tensor,
160
- torch.Tensor,
161
- ]:
162
- """
163
- Bi-partite Cholesky decomposition
164
- """
165
- n1, _, n2, _ = C.shape
166
- Eye = tensor_identity(n1, n2).type_as(C)
167
- Zero = torch.zeros(C.shape).type_as(C)
168
- unitL = Eye.clone()
169
- unitR = Eye.clone() # NOTE: unitR will compute unitL^-1
170
- L = Zero.clone() # NOTE: L = L · √D
171
- D = Zero.clone()
172
- D_pinv = Zero.clone() # NOTE: will compute D^†
173
-
174
- for i in range(n1):
175
- # update row i of unitL, L, D
176
- D[i, i, :, :] = C[i, i, :, :]
177
- for j in range(i):
178
- for k in range(i):
179
- unitL[i, j, :, :] += C[i, k, :, :] @ matrix_conj(unitR[j, k, :, :])
180
-
181
- unitL[i, j, :, :] = unitL[i, j, :, :] @ D_pinv[j, j, :, :]
182
- L[i, j, :, :] = unitL[i, j, :, :] @ L[j, j, :, :]
183
- D[i, i, :, :] = D[i, i, :, :] - L[i, j, :, :] @ matrix_conj(L[i, j, :, :])
184
-
185
- # NOTE: due to numerical imperfections, raise error if not positive within tolerance, otherwise coerce to positive
186
- D[i, i, :, :] = assert_matrix_positive(D[i, i, :, :])
187
- L[i, i, :, :] = matrix_sqrt(D[i, i, :, :])
188
-
189
- # update row i of pseudo-inverse of D
190
- D_pinv[i, i, :, :] = matrix_pinv(D[i, i, :, :])
191
-
192
- # update row i of inverse of unitL
193
- for j in range(i):
194
- for k in range(j, i):
195
- unitR[i, j, :, :] -= unitL[i, k, :, :] @ unitR[k, j, :, :]
196
-
197
- return L
198
-
199
-
200
- def algorithm_bipartite_cholesky_modified(
201
- C: torch.Tensor,
202
- /,
203
- ) -> tuple[
204
- torch.Tensor,
205
- torch.Tensor,
206
- ]:
207
- """
208
- Bi-partite Cholesky decomposition with improved numerical stability
209
-
210
- This is equivalent to the algorithm presented in the paper,
211
- modified by keeping track of the pseudo-inverse of L instead of the inverse of unitL.
212
-
213
- NOTE: This algorithm has been obtained by simple change of variables,
214
- and by observing that
215
-
216
- ```py
217
- unitL[i, j] = ... · D[j, j]
218
- L[j, j] = √D[j, j]
219
- ```
220
-
221
- whence
222
-
223
- ```py
224
- unitL[i, j] = unitL[i, j] · L[j, j] · √D[j, j]
225
- ```
226
-
227
- must hold.
228
- """
229
- n1, _, _, _ = C.shape
230
- Zero = torch.zeros(C.shape).type_as(C)
231
- L = Zero.clone() # NOTE: L = L · √D
232
- R = Zero.clone() # NOTE: will compute R := L^† = √D^† · hatL^-1
233
- D = Zero.clone()
234
-
235
- for i in range(n1):
236
- # update row i of L, D
237
- D[i, i, :, :] = C[i, i, :, :]
238
- for j in range(i):
239
- for k in range(i):
240
- L[i, j, :, :] += C[i, k, :, :] @ matrix_conj(R[j, k, :, :])
241
-
242
- D[i, i, :, :] = D[i, i, :, :] - L[i, j, :, :] @ matrix_conj(L[i, j, :, :])
243
-
244
- # NOTE: due to numerical imperfections, raise error if not positive within tolerance, otherwise coerce to positive
245
- D[i, i, :, :] = assert_matrix_positive(D[i, i, :, :])
246
- L[i, i, :, :] = matrix_sqrt(D[i, i, :, :])
247
-
248
- # update row i of R
249
- R[i, i, :, :] = matrix_pinv(L[i, i, :, :])
250
- for j in range(i):
251
- for k in range(j, i):
252
- R[i, j, :, :] -= L[i, k, :, :] @ R[k, j, :, :]
253
-
254
- R[i, j, :, :] = R[i, i, :, :] @ R[i, j, :, :]
255
-
256
- return L
257
-
258
-
259
- # ----------------------------------------------------------------
260
- # TERTIARY METHODS
261
- # ----------------------------------------------------------------
262
-
263
-
264
- def random_cp(
265
- d1: int,
266
- d2: int,
267
- /,
268
- *,
269
- precision: torch.dtype = torch.double,
270
- ) -> torch.Tensor:
271
- """
272
- Produces the Choi-matrix of a 'random' (contractive) CP-map
273
- """
274
- while True:
275
- U = torch.randn(size=(d1, d1, d2, d2), dtype=precision)
276
- C = tensor_multiply(U, tensor_conj(U))
277
- scale = scipy.linalg.norm(C)
278
- if scale > 0:
279
- C = C / scale
280
- break
281
-
282
- return C
283
-
284
-
285
- def random_cptp_choicholesky(
286
- d1: int,
287
- d2: int,
288
- /,
289
- *,
290
- precision: torch.dtype = torch.double,
291
- ) -> torch.Tensor:
292
- """
293
- Uses the resolution lemma (cf. Lemma 2.10 in §2.5)
294
- to determine the Choi matrix of a 'random' CPTP-map
295
-
296
- NOTE: The other way to generate Choi matrices of random CPTP-maps
297
- is to keep iterating over randomly generated matrices until the 2nd partial trace
298
- equals the identity (cf. Table 1 in §2.1).
299
- However, this is verify inefficient.
300
- """
301
- while True:
302
- # create random operators
303
- U = torch.randn(size=(d1, d1, d2, d2), dtype=precision)
304
-
305
- # use Gram-Schmidt to ensure that rows are tr-orthonormal
306
- success = True
307
- for i in range(d1):
308
- u = U[i, :, :, :]
309
-
310
- # ensure orthogonality
311
- for ii in range(i):
312
- v = U[ii, :, :, :]
313
- scale = torch.einsum(r"jrs,jsr", u, tensor_conj_2(v))
314
- u = u - scale * v
315
-
316
- # ensure normality
317
- scale = torch.einsum(r"jrs,jsr", u, tensor_conj_2(u))
318
- if not (scale > 0):
319
- success = False
320
- break
321
-
322
- # update row i of bi-partite lower triangular operator
323
- u /= math.sqrt(scale)
324
- U[i, :, :, :] = u
325
-
326
- if success:
327
- break
328
-
329
- C = tensor_multiply(U, tensor_conj(U))
330
- return C
331
-
332
-
333
- # ----------------------------------------------------------------
334
- # AUXILIARY METHODS
335
- # ----------------------------------------------------------------
336
-
337
-
338
- def tensor_identity(d1: int, d2: int, /) -> torch.Tensor:
339
- return torch.einsum(r"ij,kl->ijkl", torch.eye(d1), torch.eye(d2))
340
-
341
-
342
- def tensor_multiply(A: torch.Tensor, B: torch.Tensor, /) -> torch.Tensor:
343
- return torch.einsum(r"ijrs,jkst->ikrt", A, B)
344
-
345
-
346
- def tensor_conj(A: torch.Tensor, /) -> torch.Tensor:
347
- return A.conj().transpose(0, 1).transpose(2, 3)
348
-
349
-
350
- def tensor_conj_1(A: torch.Tensor, /) -> torch.Tensor:
351
- return A.conj().transpose(0, 1)
352
-
353
-
354
- def tensor_conj_2(A: torch.Tensor, /) -> torch.Tensor:
355
- k = len(A.shape)
356
- return A.conj().transpose(-2, -1)
357
-
358
-
359
- def matrix_conj(A: torch.Tensor, /) -> torch.Tensor:
360
- return tensor_conj_1(A)
361
-
362
-
363
- def matrix_multiply(A: torch.Tensor, B: torch.Tensor, /) -> torch.Tensor:
364
- return torch.einsum(r"ik,kj->ij", A, B)
365
-
366
-
367
- def assert_matrix_positive(
368
- A: torch.Tensor,
369
- /,
370
- *,
371
- tol: float = TOL,
372
- ) -> torch.Tensor:
373
- """
374
- Checks if self-adjoint matrix is positive within tolerance and forces it to be so
375
- """
376
- # check if positive
377
- values, U = torch.linalg.eigh(A)
378
- eig_min = min(values).item()
379
-
380
- if eig_min < -0.5 * tol:
381
- raise ValueError(f"operator is not positive within tolerance {tol:.4g}: min(σ(A)) = {eig_min:.4g}") # fmt: skip
382
-
383
- if eig_min < 0:
384
- logging.warning(f"operator has negative eigenvales but is positive within tolerance {tol:.4g}: min(σ(A)) = {eig_min:.4g}") # fmt: skip
385
-
386
- # force positivity by setting negative eigenvalues to 0
387
- values = torch.maximum(values, torch.zeros_like(values))
388
- sqrtD = torch.diag(torch.sqrt(values))
389
- sqrtA = U @ sqrtD
390
- A = sqrtA @ matrix_conj(sqrtA)
391
- return A
392
-
393
-
394
- def matrix_sqrt(A: torch.Tensor, /) -> torch.Tensor:
395
- """
396
- Computes the sqrt of a positive semi-definite matrix
397
- """
398
- dtype = A.dtype
399
- sqrtA = scipy.linalg.sqrtm(A)
400
- sqrtA = torch.asarray(sqrtA, dtype=dtype)
401
- return sqrtA
402
-
403
-
404
- def matrix_pinv(A: torch.Tensor, /) -> torch.Tensor:
405
- """
406
- Computes the Moore-Penrose pseudo-inverse of a semi-definite matrix
407
- """
408
- A_pinv = torch.pinverse(A)
409
- return A_pinv
410
-
411
-
412
- def trace_2(A: torch.Tensor, /) -> torch.Tensor:
413
- return torch.einsum(r"ijkk->ij", A)
414
-
415
-
416
- def tensor_diff(
417
- X: torch.Tensor,
418
- Y: torch.Tensor,
419
- /,
420
- *,
421
- rel: bool = True,
422
- ) -> float:
423
- """
424
- Computes the relative difference between two tensors
425
- """
426
- eps = torch.linalg.norm(Y - X)
427
- if rel:
428
- scale = max(torch.linalg.norm(X), torch.linalg.norm(Y)) or 1.0
429
- eps = eps / scale
430
- return float(eps)
431
-
432
-
433
- # ----------------------------------------------------------------
434
- # EXECUTION
435
- # ----------------------------------------------------------------
436
-
437
- if __name__ == "__main__":
438
- """
439
- Sessions settings including optional seeding
440
- """
441
- sys.tracebacklimit = 0
442
- args = sys.argv[1:]
443
- if len(args) > 0:
444
- seed = args[0]
445
- torch.manual_seed(seed)
446
-
447
- """
448
- Set up logging
449
- """
450
-
451
- logging.basicConfig(
452
- format="%(asctime)s $\x1b[92;1m%(name)s\x1b[0m [\x1b[1m%(levelname)s\x1b[0m] %(message)s",
453
- datefmt=r"%Y-%m-%d %H:%M:%S",
454
- encoding="utf-8",
455
- )
456
- getLogger(name="root").setLevel(logging.INFO)
457
-
458
- """
459
- Main execution
460
- """
461
- main(num_experiments=NUM_EXPERIMENTS, map_type=MAP_TYPE, mode=MODE)