Skip to content

Optimisation - #12

Open
perrymok wants to merge 11 commits into
mainfrom
optimisation
Open

Optimisation#12
perrymok wants to merge 11 commits into
mainfrom
optimisation

Conversation

@perrymok

@perrymok perrymok commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

This is an attempt at optimising the library.

Originally, the tests finish in 334.29s (0:05:34):

260.37s call     tests/test_datasets.py::test_decomposition
15.93s call     tests/test_updates.py::test_dicotomy2
14.07s call     tests/test_estimators.py::test_general
13.57s call     tests/test_datasets.py::test_generate
11.23s call     tests/test_datasets.py::test_spim
6.28s call     tests/test_updates.py::test_dicotom_aq2
3.20s call     tests/test_estimators.py::test_NMF_scikit
1.30s call     tests/test_estimators.py::test_fixed_mat
1.23s call     tests/test_updates.py::test_proj_step_h
0.65s call     tests/test_laplacian.py::test_sigma_L
0.50s call     tests/test_EDXS.py::test_generate_g_matr
0.47s call     tests/test_EDXS.py::test_NMF_simplex
0.44s call     tests/test_EDXS.py::test_NMF_initialize_W
0.31s call     tests/test_EDXS.py::test_NMF_update
0.29s call     tests/test_estimators.py::test_generate_one_sample
0.13s call     tests/test_datasets.py::test_set_fixed_W
0.12s call     tests/test_datasets.py::test_carto_fixed_W
0.12s call     tests/test_docstring.py::test_docstrings_rst
0.11s call     tests/test_updates.py::test_multiplicative_step_h
...

Now, the tests finish in 262.92s (0:04:22):

206.47s call     tests/test_datasets.py::test_decomposition
14.36s call     tests/test_estimators.py::test_general
11.72s call     tests/test_datasets.py::test_generate
7.80s call     tests/test_datasets.py::test_spim
6.09s call     tests/test_updates.py::test_dicotomy2
5.44s call     tests/test_updates.py::test_dicotom_aq2
2.59s call     tests/test_estimators.py::test_NMF_scikit
1.10s call     tests/test_estimators.py::test_fixed_mat
0.95s call     tests/test_updates.py::test_proj_step_h
0.80s call     tests/test_laplacian.py::test_sigma_L
0.25s call     tests/test_EDXS.py::test_generate_g_matr
0.20s call     tests/test_EDXS.py::test_NMF_simplex
0.17s call     tests/test_EDXS.py::test_NMF_initialize_W
0.14s call     tests/test_EDXS.py::test_NMF_update
0.11s call     tests/test_estimators.py::test_generate_one_sample
...

Both ran on my machine with i5-1135G7 with 16GB ram. I have tried running on more powerful machines but the difference is much less significant.

All changes should preserve behaviour.

Will also try to add numba and cupy capability in another PR.

Please pay particular attention to 6c5e939, which should not alter the original behaviour but it is not equivalent if called directly.

@perrymok
perrymok marked this pull request as ready for review July 23, 2026 08:17
@perrymok
perrymok requested a review from adriente July 23, 2026 14:44
@adriente

Copy link
Copy Markdown
Owner

Did you use AI to produce this PR ? If so, how much (100% of modifications or less ?) ?

@perrymok

Copy link
Copy Markdown
Collaborator Author

say 80% were produced by AI, but i manually checked all changes to make sure they are equivalent to the original

@adriente

Copy link
Copy Markdown
Owner

I have mixed feelings.
For the pros :

  • A lot of the modifications seems to work well and the code looks better (e.g. using match / case )
  • The results you sent, show a significant speed improvement

For the cons :

  • It is a lot of modifications (that's the aim of automation), it is thus hard to review.
  • Some functions of the code are quite complex and it is hard to tell if the AI really improved or just put dust under the carpet. I am mostly thinking about dichotomy.py . We struggled a lot with this dichotomy and it produces a lot of errors, some of the errors are really hard to identify.
  • I spotted one function that changed : squared_distance in measures.py , The documentation example does not work after modification. While the code is technically correct (it runs), it does not do what is intended anymore.

I am not saying we shouldn't use AI (I have also mixed feelings about it, to be honest), but I know it is a powerful tool. But I believe we should do the following :

  • Use AI for non-critical parts of the package, that's fine with me. I am thinking about concentration_report from eds_spim.py for example.
  • note when AI is used (and, in one sentence, how)
  • Be very cautious when using AI for critical parts of the code.

For this optimisation PR, ideally I would like that for the espm.estimators folder a pre-PR study is performed. Identifying the bottleneck in terms of computation speed. Then, perform optimisation with, if necessary, the help of AI.

I am opened to discussion on the topic.

@adriente

Copy link
Copy Markdown
Owner

I have to say, that for squared_distance the documentation is wrong. 😆

@perrymok

Copy link
Copy Markdown
Collaborator Author

I spotted one function that changed : squared_distance in measures.py , The documentation example does not work after modification. While the code is technically correct (it runs), it does not do what is intended anymore.

I am pretty sure the optimised code is equivalent btw. This script runs without panicking:

import numpy as np


def original(x, y=None):
    try:
        x.shape[1]
    except IndexError:
        x = x.reshape(1, x.shape[0])
    if y is None:
        y = x
    else:
        try:
            y.shape[1]
        except IndexError:
            y = y.reshape(1, y.shape[0])
    rx, cx = x.shape
    ry, cy = y.shape
    if cx != cy:
        raise ValueError("The sizes of x and y do not fit")
    xx = (x * x).sum(axis=1)
    yy = (y * y).sum(axis=1)
    xy = np.dot(x, y.T)
    d = abs(np.kron(np.ones((ry, 1)), xx).T + np.kron(np.ones((rx, 1)), yy) - 2 * xy)

    return d / cx


def opt(x, y=None):
    try:
        x.shape[1]
    except IndexError:
        x = x.reshape(1, x.shape[0])
    if y is None:
        y = x
    else:
        try:
            y.shape[1]
        except IndexError:
            y = y.reshape(1, y.shape[0])
    rx, cx = x.shape
    ry, cy = y.shape
    if cx != cy:
        raise ValueError("The sizes of x and y do not fit")
    xx = (x * x).sum(axis=1)
    yy = (y * y).sum(axis=1)
    xy = np.dot(x, y.T)
    d = np.abs(xx[:, np.newaxis] + yy[np.newaxis, :] - 2 * xy)

    return d / cx


for c in range(1, 100):
    x = np.random.random(size=(c + 1, c))
    y = np.random.random(size=(c + 2, c))

    assert np.array_equal(original(x, y), opt(x, y))

If you give the documentation example (np.arange(3)) to the original code, it still gives [0.]. What you intended to give as the example should be np.arange(3)[:, np.newaxis].

I do admit that this change is non-trivial. Let me list the non-trivial changes made in this PR by commenting. But at the end of the day, I think the only change that is breaking equivalence is 6c5e939.

Comment thread espm/datasets/eds_spim.py
W4 = -1 * np.ones((2, brstlg_comps))
W_brstlg = np.vstack((W3, W4))
W = np.hstack((W_elts, W_brstlg))
L = len(elements)

@perrymok perrymok Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is quite a big change but if u look closely it's way more elegant than the original method, and actually much easier to understand

Comment thread espm/datasets/eds_spim.py
ranges_list = [(values[2 * i - 1], values[2 * i]) for i in range(1, num + 1)]
return ranges_list
values = np.linspace(axis.low_value, axis.high_value, num=2 * num + 2)
return values[1:-1].reshape(num, 2).tolist()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

np.reshape can be a bit hard to understand

Comment thread espm/datasets/eds_spim.py
for i, p in enumerate(areas_dict):
H[i, :, :] = areas_dict[p]

H = np.array(list(areas_dict.values()))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a pretty cool vectorisation.

a = np.apply_along_axis(max_masked, 0, num)
val = num / 2 - denum
val[num <= 0] = -np.inf
a = np.max(val, axis=0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

complicated

@perrymok

Copy link
Copy Markdown
Collaborator Author

other than the commented changes, the rest are either trivial use of better np functions, or list/dictionary comprehension, or refractoring of common code, or etc

@perrymok

Copy link
Copy Markdown
Collaborator Author

regarding the use of AI, i generally only use AI for tasks that are not as significant, like the good-practices PR and this one. I have actually withheld many more AI optimisations that I couldn't verify the equivalence (there are hidden assumptions that AI can see but I cant).

@perrymok

Copy link
Copy Markdown
Collaborator Author

For this optimisation PR, ideally I would like that for the espm.estimators folder a pre-PR study is performed. Identifying the bottleneck in terms of computation speed. Then, perform optimisation with, if necessary, the help of AI.

that is a good idea, we can do that in another PR. for this PR is just small changes that are equivalent.

@perrymok

Copy link
Copy Markdown
Collaborator Author

Massive speed-up by associating matrix multiplication to lower FLOPs one.

Before: (G.T @ (X / GWH)) @ H.T -> (m, p) * (p, k) FLOPs
After: G.T @ ((X / GWH) @ H.T) -> (m, n) * (n, k) FLOPs

where

  • m: # of physical components
  • n: # of energy channels
  • k: n_components
  • p: # of pixels

p is dominating, so the one before takes up significantly more FLOPs.

Before (245.22s):

200.81s call     tests/test_datasets.py::test_decomposition
9.75s call     tests/test_estimators.py::test_general
8.33s call     tests/test_datasets.py::test_generate
6.90s call     tests/test_datasets.py::test_spim
5.45s call     tests/test_updates.py::test_dicotomy2
4.95s call     tests/test_updates.py::test_dicotom_aq2
2.48s call     tests/test_estimators.py::test_NMF_scikit
0.90s call     tests/test_estimators.py::test_fixed_mat
0.88s call     tests/test_updates.py::test_proj_step_h
0.62s call     tests/test_laplacian.py::test_sigma_L
0.19s call     tests/test_EDXS.py::test_generate_g_matr
0.18s call     tests/test_EDXS.py::test_NMF_simplex
0.14s call     tests/test_EDXS.py::test_NMF_initialize_W
0.12s call     tests/test_EDXS.py::test_NMF_update
...

After (234.27s):

190.61s call     tests/test_datasets.py::test_decomposition
8.18s call     tests/test_datasets.py::test_generate
7.45s call     tests/test_datasets.py::test_spim
6.94s call     tests/test_estimators.py::test_general
5.56s call     tests/test_updates.py::test_dicotomy2
4.99s call     tests/test_updates.py::test_dicotom_aq2
2.61s call     tests/test_estimators.py::test_NMF_scikit
0.97s call     tests/test_docstring.py::test_docstrings_rst
0.93s call     tests/test_updates.py::test_proj_step_h
0.79s call     tests/test_estimators.py::test_fixed_mat
0.58s call     tests/test_laplacian.py::test_sigma_L
0.20s call     tests/test_EDXS.py::test_NMF_simplex
0.19s call     tests/test_EDXS.py::test_generate_g_matr
0.15s call     tests/test_EDXS.py::test_NMF_update
0.15s call     tests/test_EDXS.py::test_NMF_initialize_W
0.11s call     tests/test_estimators.py::test_generate_one_sample
...

@perrymok

Copy link
Copy Markdown
Collaborator Author

for this function, i realised that the documentation which mentions that a and b can be float is incorrect, since a[...] and b[...] were originally called, which will panic on floats.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants