Skip to content

Commit 31af143

Browse files
committed
Improve documentation, update workflows
1 parent af923e5 commit 31af143

File tree

7 files changed

+170
-70
lines changed

7 files changed

+170
-70
lines changed

.github/workflows/docs.yml

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,24 @@ jobs:
1717
docs:
1818
name: Documentation
1919
runs-on: ubuntu-latest
20-
container:
21-
image: cupy/cupy:v13.4.0
2220

2321
steps:
2422
- uses: actions/checkout@v3
2523

2624
- name: Clean previous builds
2725
run: rm -rf build dist *.egg-info
2826

29-
- name: Set up git
30-
run: apt-get update && apt-get install git -y
31-
3227
- name: Install system dependencies
3328
run: |
34-
apt-get update && apt-get install -y python3-venv python3-pip
29+
sudo apt-get update
30+
sudo apt-get install -y python3.12 python3.12-venv python3-pip git
3531
36-
- name: Install additional dependencies
32+
- name: Create virtual environment and install package
3733
run: |
38-
python3.10 -m venv venv
34+
python3.12 -m venv venv
3935
source venv/bin/activate
40-
python3.10 -m pip install --no-cache-dir .
36+
python3.12 -m pip install --upgrade pip
37+
python3.12 -m pip install --no-cache-dir .
4138
shell: bash -e {0}
4239

4340
- name: Generate documentation
@@ -46,7 +43,7 @@ jobs:
4643
cd docs
4744
make html
4845
shell: bash -e {0}
49-
46+
5047
- name: Deploy
5148
if: success()
5249
uses: peaceiris/actions-gh-pages@v3

.github/workflows/tests.yml

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,35 +16,43 @@ on:
1616

1717
jobs:
1818
test:
19-
name: Testing using pytest
19+
name: Testing on Python ${{ matrix.python-version }}
2020
runs-on: ubuntu-latest
21-
container:
22-
image: cupy/cupy:v13.4.0
21+
22+
strategy:
23+
fail-fast: false
24+
matrix:
25+
python-version: ['3.10', '3.11', '3.12']
2326

2427
steps:
2528
- uses: actions/checkout@v3
2629

30+
- name: Set up Python ${{ matrix.python-version }}
31+
uses: actions/setup-python@v5
32+
with:
33+
python-version: ${{ matrix.python-version }}
34+
2735
- name: Clean previous builds
2836
run: rm -rf build dist *.egg-info
2937

3038
- name: Install system dependencies
3139
run: |
32-
apt-get update && apt-get install -y python3.10-venv python3-pip cmake
40+
sudo apt-get update
41+
sudo apt-get install -y cmake
3342
34-
- name: Install additional dependencies
43+
- name: Install Python dependencies
3544
run: |
36-
python3.10 -m venv venv
45+
python -m venv venv
3746
source venv/bin/activate
38-
python3.10 -m pip install --no-cache-dir .
39-
shell: bash -e {0}
47+
python -m pip install --upgrade pip
48+
pip install --no-cache-dir .
49+
shell: bash
4050

41-
- name: Test with pytest
51+
- name: Run tests with pytest
4252
run: |
4353
source venv/bin/activate
4454
mkdir -p build
4555
cmake -B build -S .
46-
cd build
47-
make
48-
cd ..
49-
python3.10 -m pytest
50-
shell: bash -e {0}
56+
cmake --build build
57+
pytest
58+
shell: bash

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ TO DO:
1212
4) Accuracy vs efficiency
1313
5) Script to run on Ulysses
1414
6) Add missing tests
15+
7) Possibly delete eigenvalues\_np and so on, which I inserted because I thought it was not going to be necessary to compute the eigenvectors, making these wrappers useless
16+
8) Write missing documentation

docs/index.rst

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ final_project documentation
77
===========================
88

99
Welcome to the documentation for the final project of the course in Development Tools for Scientific Computing.
10-
We are currently working on the project and on the documentation.
11-
Stay tuned!
10+
Our goal is to compute an efficient solver for eigenvalue problems.
1211

13-
Recall that the documentation is written following
14-
`reStructuredText <https://www.sphinx-doc.org/en/master/usage/restructuredtext/index.html>`_
12+
For theoretical details, please check the file docs/Documentation.ipynb
13+
To install the package, please read the README.md file.
1514

1615

1716
Table of Contents
@@ -30,4 +29,4 @@ Module Documentation
3029
:members:
3130
:undoc-members:
3231
:show-inheritance:
33-
:noindex:
32+
:noindex:

docs/pyclassify.rst

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,13 @@ Submodule: utils
1717
.. automodule:: pyclassify.utils
1818
:members:
1919
:undoc-members:
20-
:show-inheritance:
20+
:show-inheritance:
21+
22+
Submodule: helpers_secular
23+
================
24+
25+
.. automodule:: pyclassify.helpers_secular
26+
:members:
27+
:undoc-members:
28+
:show-inheritance:
29+

setup.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import sys
33
import shutil
4+
import glob
45
from setuptools import setup, find_packages, Extension
56
from setuptools.command.build_ext import build_ext
67

@@ -21,22 +22,19 @@ def build_extension(self, ext):
2122
self.spawn(["cmake", ext.sourcedir, "-B", build_temp])
2223
self.spawn(["cmake", "--build", build_temp, "--target", "QR_cpp"])
2324

24-
python_version = sys.version_info
25-
so_filename = (
26-
f"QR_cpp.cpython-{python_version[0]}{python_version[1]}-x86_64-linux-gnu.so"
27-
)
28-
29-
src_lib = os.path.join(build_temp, f"../../src/pyclassify/{so_filename}")
30-
dst_lib = os.path.join(build_lib, f"pyclassify/{so_filename}")
31-
32-
if os.path.exists(src_lib):
33-
os.makedirs(os.path.dirname(dst_lib), exist_ok=True)
34-
shutil.copy(src_lib, dst_lib)
35-
else:
25+
# Dynamically find the compiled shared library
26+
matches = glob.glob(os.path.join(build_temp, "../../src/pyclassify/QR_cpp*.so"))
27+
if not matches:
3628
raise RuntimeError(
37-
f"Could not find compiled QR_cpp shared library at {src_lib}!"
29+
"Could not find compiled QR_cpp shared library in expected location."
3830
)
3931

32+
src_lib = os.path.abspath(matches[0])
33+
dst_lib = os.path.join(build_lib, "pyclassify", os.path.basename(src_lib))
34+
35+
os.makedirs(os.path.dirname(dst_lib), exist_ok=True)
36+
shutil.copy(src_lib, dst_lib)
37+
4038

4139
setup(
4240
name="pyclassify",
@@ -48,7 +46,7 @@ def build_extension(self, ext):
4846
ext_modules=[CMakeExtension("pyclassify.QR_cpp")],
4947
packages=find_packages(where="src/"),
5048
package_dir={"": "src/"},
51-
package_data={"pyclassify": ["QR_cpp.cpython-312-x86_64-linux-gnu.so"]},
49+
package_data={"pyclassify": ["QR_cpp*.so"]},
5250
include_package_data=True,
5351
cmdclass={"build_ext": CMakeBuild},
5452
zip_safe=False,

src/pyclassify/helpers_secular.py

Lines changed: 113 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,34 @@
33

44

55
def inner_outer_eigs(eigs, rho):
6+
"""
7+
Splits the eigenvalues into inner eigenvalues and the outer eigenvalue based on the sign of rho.
8+
9+
Parameters:
10+
eigs (np.ndarray or scipy.sparse.spmatrix): Array of eigenvalues, assumed to be sorted.
11+
rho (float): Scalar parameter appearing in the secular function (please refer to the documentation for more detailed info).
12+
13+
Returns:
14+
tuple: A tuple (inner_eigs, outer_eig) where inner_eigs is an array of eigenvalues and outer_eig is a scalar.
15+
If rho > 0, the last eigenvalue is considered outer due to the interlacing property; otherwise, the first is.
16+
"""
617
inner_eigs = eigs[:-1] if rho > 0 else eigs[1:]
718
outer_eig = eigs[-1] if rho > 0 else eigs[0]
819
return inner_eigs, outer_eig
920

1021

1122
def return_secular_f(rho, d, v):
1223
"""
13-
This returns f as a callable object (function of lambda). f is built using rho, d, v.
14-
This function passes the tests in test.py and is likely implemented correctly.
24+
Constructs the secular function for a rank-one update to a diagonal matrix.
25+
26+
Parameters:
27+
rho (float): Scalar from the rank-one matrix update.
28+
d (np.ndarray or scipy.sparse.spmatrix): 1D array or sparse vector of diagonal entries.
29+
v (np.ndarray or scipy.sparse.spmatrix): 1D array or sparse vector used in the rank-one update.
30+
31+
Returns:
32+
callable:
33+
f(lambda_: float) -> float: The secular function evaluated at lambda_.
1534
"""
1635

1736
def f(lambda_):
@@ -25,27 +44,53 @@ def f(lambda_):
2544

2645
def secular_function(mu, rho, d, v2, i):
2746
"""
28-
Needed to compute the zeros of the secular function in the i-th subinterval;
47+
Evaluates the secular function at a given point in the i-th subinterval.
48+
49+
Parameters:
50+
mu (float): Point at which to evaluate the secular function.
51+
rho (float): Scalar from the rank-one matrix update.
52+
d (np.ndarray or scipy.sparse.spmatrix): 1D array or sparse vector of diagonal entries.
53+
v2 (np.ndarray or scipy.sparse.spmatrix): Elementwise square of the update vector v (i.e., v ** 2).
54+
i (int): Index of the subinterval in which mu lies.
55+
56+
Returns:
57+
float: The value of the secular function at mu.
2958
"""
3059
psi1, _, psi2, _ = compute_psi_s(mu, rho, d, v2, i)
3160
return 1 + psi1 + psi2
3261

3362

3463
def check_is_root(f, x, tol=1e-7):
3564
"""
36-
Usually the values of f(found_eigenvalue) are around 1e-10, so we cannot be *too* restrictive with the threshold.
37-
For instance, using np.isclose, sometimes the checks are not passed, even though f(found_eig) is very small in
38-
absolute value and we are indeed very close to one.
39-
That is the reason for defining a helper function.
65+
Determines whether x is a root of the function f within a given numerical tolerance.
66+
Written because np.isclose is too restrictive even in cases in which we are indeed close to an eigenvalue.
67+
68+
Parameters:
69+
f (callable): Function to evaluate.
70+
x (float): Point to test as a root.
71+
tol (float): Absolute tolerance for considering f(x) close to zero.
72+
73+
Returns:
74+
bool: True if |f(x)| < tol, indicating x is a root within the specified tolerance.
4075
"""
4176
return np.abs(f(x)) < tol
4277

4378

4479
def bisection(f, a, b, tol, max_iter):
4580
"""
46-
In the main method, we used a slightly tweaked form of bisection for the eig. in the outer interval (i.e. lambda_0 if rho < 0,
47-
else lambda_{n-1}.
48-
This helper function implements standard bisection and it is used in the function compute_outer_zero.
81+
Standard bisection method to find a root of the function f in the interval [a, b].
82+
83+
This implementation is used in `compute_outer_zero` to locate the outer eigenvalue.
84+
85+
Parameters:
86+
f (callable): A continuous function for which f(a) * f(b) < 0.
87+
a (float): Left endpoint of the interval.
88+
b (float): Right endpoint of the interval.
89+
tol (float): Tolerance for convergence. The method stops when the interval is smaller than tol or when f(c) is sufficiently small.
90+
max_iter (int): Maximum number of iterations before stopping.
91+
92+
Returns:
93+
float: Approximation of the root within the specified tolerance.
4994
"""
5095
iter_count = 0
5196

@@ -65,17 +110,27 @@ def bisection(f, a, b, tol, max_iter):
65110

66111
def compute_outer_zero(f, rho, interval_end, v, tol=1e-12, max_iter=2000):
67112
"""
68-
Function to compute the outer eigenvalue (lambda[0] if rho < 0, else lambda[n-1]).
69-
What it does is the following:
70-
1) depending on rho, understand whether we should look for it in (d[n-1],+ \infty) or (-\infty, d[0])
71-
2) use bisection as follows:
72-
2a) Fix the bisection interval. Notice that (assuming rho > 0, else it is equivalent but specular)
73-
f(d[n-1]+\epsilon)\approx -\infty, in particular f(d[n-1]+\epsilon)<0.
74-
So we just need to find r\in\mathbb{R} such that f(d[n-1]+r)>0, and we can regularly use bisection.
75-
2b) To find that value of r, we just add arbitrary values to d[n-1] until the condition is satisfied.
76-
2c) Bisection is then used
77-
78-
Notice that this function passes all the tests, and I assume it is implemented correctly.
113+
Computes the outer eigenvalue (lambda[0] if rho < 0, lambda[n-1] if rho > 0) of a rank-one modified diagonal matrix.
114+
115+
The secular function behaves such that:
116+
- If rho > 0, the outer eigenvalue lies in (d[n-1], infty), and f is increasing in this interval.
117+
- If rho < 0, the outer eigenvalue lies in (-infty, d[0]), and f is decreasing in this interval.
118+
119+
This function:
120+
1. Determines the direction to search based on the sign of rho.
121+
2. Finds an upper bound (or lower bound for rho < 0) where the secular function changes sign.
122+
3. Uses the bisection method to find the root in the determined interval.
123+
124+
Parameters:
125+
f (callable): The secular function to find a root of.
126+
rho (float): Scalar rank-one update parameter.
127+
interval_end (float): Either d[0] or d[n-1], depending on the sign of rho.
128+
v (np.ndarray or scipy.sparse.spmatrix): Vector from the rank-one update; used to scale the search step size.
129+
tol (float, optional): Convergence tolerance. Default is 1e-12.
130+
max_iter (int, optional): Maximum number of bisection iterations. Default is 2000.
131+
132+
Returns:
133+
float: Approximation of the outer eigenvalue.
79134
"""
80135
threshold = 1e-11
81136
update = np.linalg.norm(v)
@@ -97,10 +152,18 @@ def compute_outer_zero(f, rho, interval_end, v, tol=1e-12, max_iter=2000):
97152

98153
def compute_psi_s(lambda_guess, rho, d, v_squared, i):
99154
"""
100-
This function computes psi1, psi2 and their derivatives.
101-
Corresponding to the interval (d[i], d[i+1]).
102-
Unless I made some mistake, in case rho!=0, it should be sufficient to multiply all the sums by rho, which is what
103-
is done here and seems to work in case rho > 0.
155+
Computes partial sums (psi1, psi2) and their derivatives for the secular function
156+
in the i-th interval (d[i], d[i+1]).
157+
158+
Parameters:
159+
lambda_guess (float): Evaluation point for the secular function.
160+
rho (float): Scalar rank-one update parameter.
161+
d (np.ndarray or scipy.sparse.spmatrix): 1D array of diagonal entries.
162+
v_squared (np.ndarray or scipy.sparse.spmatrix): Precomputed elementwise square of the update vector v.
163+
i (int): Index defining the interval (d[i], d[i+1]).
164+
165+
Returns:
166+
tuple: (psi1, psi1', psi2, psi2') — the partial secular sums and their derivatives.
104167
"""
105168
denom1 = d[: i + 1] - lambda_guess
106169
denom2 = d[i + 1 :] - lambda_guess
@@ -113,7 +176,19 @@ def compute_psi_s(lambda_guess, rho, d, v_squared, i):
113176

114177
def compute_inner_zero(rho, d, v, i, tol=1e-12, max_iter=1000):
115178
"""
116-
This function (or some of its dependencies) surely contains a bug.
179+
Computes the i-th eigenvalue that lies in the interval (d[i], d[i+1]) for a
180+
rank-one modified diagonal matrix using the secular equation.
181+
182+
Parameters:
183+
rho (float): Rank-one update scalar.
184+
d (np.ndarray or scipy.sparse.spmatrix): 1D array of diagonal entries, sorted in ascending order.
185+
v (np.ndarray or scipy.sparse.spmatrix): 1D update vector.
186+
i (int): Index indicating the interval (d[i], d[i+1]) to find the zero in.
187+
tol (float, optional): Tolerance for root-finding. Default is 1e-12.
188+
max_iter (int, optional): Maximum iterations for bisection. Default is 1000.
189+
190+
Returns:
191+
float: The computed inner eigenvalue in the interval (d[i], d[i+1]).
117192
"""
118193

119194
# fix the correct interval
@@ -152,6 +227,18 @@ def compute_inner_zero(rho, d, v, i, tol=1e-12, max_iter=1000):
152227

153228

154229
def compute_eigenvalues(rho, d, v):
230+
"""
231+
Computes all eigenvalues of a rank-one modified diagonal matrix D + rho * v v^T
232+
using the secular equation method.
233+
234+
Parameters:
235+
rho (float): Rank-one update scalar.
236+
d (np.ndarray or scipy.sparse.spmatrix): 1D array of sorted diagonal entries of D.
237+
v (np.ndarray or scipy.sparse.spmatrix): Update vector v in the rank-one perturbation.
238+
239+
Returns:
240+
np.ndarray: Sorted array of all eigenvalues of the perturbed matrix.
241+
"""
155242
f = return_secular_f(rho, d, v)
156243
eigenvalues = []
157244
iter_range = range(len(d) - 1)

0 commit comments

Comments
 (0)