Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose sparse NNLS to Python #42

Merged
merged 4 commits into from
Mar 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- run: pip install numpy
- run: pip install numpy scipy
- run: sudo apt-get install libsuitesparse-dev libbtbb-dev liblapack-dev libcfitsio-dev libmetis-dev libgsl-dev
- run: cmake -DPython_EXECUTABLE=$(which python) . && make && sudo make install
- run: python -c "import photospline"
Expand All @@ -53,7 +53,7 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- run: pip install numpy
- run: pip install numpy scipy
- run: brew install suite-sparse cfitsio gsl
- run: cmake -DPython_EXECUTABLE=$(which python) . && make && make install
- run: python -c "import photospline"
Expand All @@ -78,7 +78,7 @@ jobs:
githubToken: ${{ github.token }}
install: |
apt-get update -q -y
apt-get install -q -y cmake clang libsuitesparse-dev libbtbb-dev liblapack-dev libcfitsio-dev libmetis-dev libgsl-dev python3-dev python3-numpy
apt-get install -q -y cmake clang libsuitesparse-dev libbtbb-dev liblapack-dev libcfitsio-dev libmetis-dev libgsl-dev python3-dev python3-numpy python3-scipy
run: |
cmake -DPython_EXECUTABLE=$(which python3) .
make
Expand Down
10 changes: 9 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
cmake_minimum_required (VERSION 3.1.0 FATAL_ERROR)
cmake_policy(VERSION 3.1.0)

project (photospline VERSION 2.2.1 LANGUAGES C CXX)
project (photospline VERSION 2.3.0 LANGUAGES C CXX)

SET(CMAKE_CXX_STANDARD 11)
SET(CMAKE_C_STANDARD 99)
Expand Down Expand Up @@ -394,6 +394,14 @@ if(BUILD_SPGLAM)
WORKING_DIRECTORY ${CMAKE_BUILD_DIR}
)
LIST (APPEND ALL_TESTS photospline-test-pyfit)
if(BUILD_SPGLAM)
ADD_TEST(NAME photospline-test-nnls
COMMAND ${PYTHON_EXECUTABLE} ${PROJECT_SOURCE_DIR}/test/test_nnls.py
WORKING_DIRECTORY ${CMAKE_BUILD_DIR}
)
set_property(TEST photospline-test-nnls PROPERTY ENVIRONMENT PYTHONPATH=${PROJECT_BINARY_DIR})
LIST (APPEND ALL_TESTS photospline-test-nnls)
endif()
endif()
endif()

Expand Down
149 changes: 149 additions & 0 deletions src/python/photosplinemodule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,9 @@ pysplinetable_deriv(pysplinetable* self, PyObject* args, PyObject* kwds){
static PyObject*
pyphotospline_glam_fit(PyObject* self, PyObject* args, PyObject* kwds);

static PyObject*
pyphotospline_nnls(PyObject* self, PyObject* args, PyObject* kwds);

#ifdef HAVE_NUMPY
static PyObject*
pysplinetable_grideval(pysplinetable* self, PyObject* args, PyObject* kwds);
Expand Down Expand Up @@ -1313,6 +1316,19 @@ static PyMethodDef photospline_methods[] = {
":param monodim: if specified, the fit function will be monotonic (strictly non-decreasing) along the given axis\n"
":param verbose: if True, will print status information to standard output while running\n"
":returns: a spline table object\n"},
{"nnls", (PyCFunction)pyphotospline_nnls, METH_VARARGS | METH_KEYWORDS,
"Solve argmin_x || Ax - b ||_2 for x>=0, where A is a sparse matrix\n\n"
":param A: model matrix\n"
":param b: right-hand side vector\n"
":param tolerance: terminate once the largest element of the gradient is smaller than the tolerance\n"
":param min_iterations: minimum number of iterations (least-squares solutions in a constrained subspace) before terminating\n"
":param max_iterations: minimum number of iterations (least-squares solutions in a constrained subspace) before terminating\n"
":param npos: maximum number of positive coefficients to return\n"
":param normaleq: if True, the inputs are the normal equations At*A and At*b instead of A and b\n"
":param verbose: print debugging info\n"
":returns: x, the solution vector\n\n"
"This is a wrapper around a sparse implementation of Algorithm NNLS from \"Solving Least Squares Problems\", Charles Lawson and Richard Hanson. Prentice-Hall, 1974.\n"
},
#endif
{"bspline", (PyCFunction)pyphotospline_bspline, METH_VARARGS | METH_KEYWORDS,
"Evaluate the `i`th B-spline on knot vector `knots` at `x`\n\n"
Expand Down Expand Up @@ -1768,6 +1784,139 @@ pysplinetable_grideval(pysplinetable* self, PyObject* args, PyObject* kwds){
}
#endif

namespace {
namespace detail {

// A simple self-destructing wrapper around PyObject*
struct ref {
static inline void decref(PyObject *x) { Py_DECREF(x); };
std::unique_ptr<PyObject, decltype(&decref)> px;
ref(PyObject *ptr) : px(ptr, &decref) {};
operator bool() { return bool(px); };
operator PyObject*() { return px.get(); };
PyObject* ptr() { return px.get(); }
};

}
}

static PyObject*
pyphotospline_nnls(PyObject* self, PyObject* args, PyObject* kwds){
static const char* kwlist[] = {"A", "b", "tolerance", "min_iterations", "max_iterations", "npos", "normaleq", "verbose", NULL};

PyObject* py_A=NULL;
PyObject* py_b=NULL;
const char *method;
double tolerance=0;
int min_iterations=0;
int max_iterations=INT_MAX;
unsigned npos=0;
bool normaleq=false;
bool verbose=false;

if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|diiIpp", (char**)kwlist,
&py_A, &py_b, &tolerance,
&min_iterations, &max_iterations, &npos, &normaleq, &verbose))
return(NULL);

cholmod_common cholmod_state;
cholmod_l_start(&cholmod_state);

// create a cholmod_sparse view into csc matrix A
cholmod_sparse sparse_A;
{
detail::ref sparse(PyImport_ImportModule("scipy.sparse"));
if (!sparse) {
return(NULL);
}
detail::ref isspmatrix_csc(PyObject_GetAttrString(sparse, "isspmatrix_csc"));
if (!isspmatrix_csc) {
return(NULL);
}
if (PyObject_CallFunctionObjArgs(isspmatrix_csc, py_A, NULL) != Py_True) {
detail::ref type(PyObject_Type(py_A));
detail::ref name(PyObject_GetAttrString(type, "__name__"));
PyErr_Format(PyExc_TypeError, "Expected scipy.sparse.csc_matrix, got %S", name.ptr());
return(NULL);
}
}
detail::ref data(
PyArray_FromAny(
detail::ref(PyObject_GetAttrString(py_A, "data")),
PyArray_DescrFromType(NPY_DOUBLE), 1, 1, NPY_ARRAY_C_CONTIGUOUS, NULL
));
if (!data) {
return(NULL);
}
detail::ref indices(
PyArray_FromAny(
detail::ref(PyObject_GetAttrString(py_A, "indices")),
PyArray_DescrFromType(NPY_LONGLONG), 1, 1, NPY_ARRAY_C_CONTIGUOUS, NULL
));
if (!indices) {
return(NULL);
}
detail::ref indptr(
PyArray_FromAny(
detail::ref(PyObject_GetAttrString(py_A, "indptr")),
PyArray_DescrFromType(NPY_LONGLONG), 1, 1, NPY_ARRAY_C_CONTIGUOUS, NULL
));
if (!indptr) {
return(NULL);
}

sparse_A.itype = cholmod_state.itype;
sparse_A.xtype = CHOLMOD_REAL;
sparse_A.dtype = CHOLMOD_DOUBLE;
sparse_A.stype = 0; // unsymmetric; all nonzero entries stored
sparse_A.nz = NULL; // packed storage
sparse_A.z = NULL; // real matrix
sparse_A.sorted = true;
sparse_A.packed = true;
{
detail::ref shape = PyObject_GetAttrString(py_A, "shape");
sparse_A.nrow = PyLong_AsLong(PyTuple_GetItem(shape, 0));
sparse_A.ncol = PyLong_AsLong(PyTuple_GetItem(shape, 1));
}
{
detail::ref nnz(PyObject_GetAttrString(py_A, "nnz"));
sparse_A.nzmax = PyLong_AsLong(nnz);
}
sparse_A.p = ExtractPyArrayDataPtr(indptr.ptr());
sparse_A.i = ExtractPyArrayDataPtr(indices.ptr());
sparse_A.x = ExtractPyArrayDataPtr(data.ptr());

// cholmod_dense view into dense matrix b
detail::ref array_b(PyArray_FromAny(py_b, PyArray_DescrFromType(NPY_DOUBLE), 1, 1, NPY_ARRAY_C_CONTIGUOUS, NULL));
cholmod_dense dense_b;
dense_b.xtype = CHOLMOD_REAL;
dense_b.dtype = CHOLMOD_DOUBLE;
dense_b.nzmax = PyArray_SIZE((PyArrayObject*)array_b.ptr());
dense_b.nrow = dense_b.nzmax;
dense_b.ncol = 1;
dense_b.d = dense_b.nrow;
dense_b.x = ExtractPyArrayDataPtr(array_b.ptr());
dense_b.z = NULL;

cholmod_dense *dense_x = nnls_lawson_hanson(&sparse_A, &dense_b,
tolerance,/* double tolerance */
min_iterations, /* unsigned min_iterations */
max_iterations, /* unsigned max_iterations */
npos, /* unsigned npos */
normaleq, /* int normaleq */
verbose, /* verbose */
&cholmod_state
);
npy_intp dims = dense_x->nrow;
PyObject *x = PyArray_SimpleNew(1, &dims, NPY_DOUBLE);
std::copy((double*)(dense_x->x), (double*)(dense_x->x)+dense_x->nrow, (double*)ExtractPyArrayDataPtr(x));

cholmod_l_free_dense(&dense_x, &cholmod_state);
cholmod_l_finish(&cholmod_state);

return x;
}

#endif //PHOTOSPLINE_INCLUDES_SPGLAM

static int
Expand Down
31 changes: 31 additions & 0 deletions test/test_nnls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import unittest
import numpy as np
import photospline


class TestNNLS(unittest.TestCase):
def setUp(self):
try:
from scipy import sparse # type: ignore[import]
except ImportError:
raise unittest.SkipTest("test requires scipy")

self.A = np.array([[1, 0], [1, 0], [0, 1]])
self.Asp = sparse.csc_matrix(self.A)
self.b = np.array([2, 1, 1])

def testSparseIsSparse(self):
self.assertEqual(len(self.Asp.data), 3)

def testImplementationMatchesScipy(self):
from scipy import optimize # type: ignore[import]

np.testing.assert_allclose(
photospline.nnls(self.Asp, self.b),
optimize.nnls(self.A, self.b)[0],
err_msg="sparse result does not aggree with dense",
)


if __name__ == "__main__":
unittest.main()
14 changes: 13 additions & 1 deletion typings/photospline-stubs/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Sequence, Union, Any, overload
from typing import Annotated, Sequence, Union, Any, overload
from os import PathLike
import numpy as np
from scipy import sparse
import numpy.typing as npt

class SplineTable:
Expand Down Expand Up @@ -79,3 +80,14 @@ def glam_fit(
monodim: None | int = None,
verbose: int = 1,
) -> SplineTable: ...

def nnls(
A: sparse.csc_matrix,
b: npt.NDArray[np.float64],
tolerance: float=0.,
min_iterations: int=0,
max_iterations: int=np.iinfo(np.int32).max,
npos: int=0,
normaleq: bool=False,
verbose: bool=False,
) -> npt.NDArray[np.float64]: ...
Loading