This example shows how to use non-negative matrix factorization (NMF) to decompose a fUSI recording into non-negative spatial maps and their associated non-negative time courses. It complements the PCA and FastICA examples in the same gallery.
NMF is unique among the decomposers in ConfUSIus because it requires strictly non-negative inputs1. Power Doppler fUSI signals are non-negative by construction, so they pass the constraint directly. However, raw power is dominated by each voxel's baseline intensity, which can make bright vessels dominate the factorization.
A practical workaround is to center and scale each voxel across time, then split the standardized signal into separate positive and negative channels, preserving NMF's non-negativity constraint. NMF can then discover additive components in above-baseline and below-baseline fluctuations separately.
Load a fUSI recording¶
We use the same spontaneous activity recording from the Nunez-Elizalde 2022 dataset as in the PCA and FastICA examples. See the Datasets user guide for more details on how to download this dataset using ConfUSIus.
from pathlib import Path
import matplotlib as mpl
import matplotlib.pyplot as plt
import xarray as xr
import confusius as cf
# Adapt background color to the current Matplotlib style.
bg_color = mpl.colors.to_hex(mpl.rcParams["figure.facecolor"])
# Keep notebook output compact for large DataArray displays.
xr.set_options(display_expand_data=False)
bids_root = cf.datasets.fetch_nunez_elizalde_2022(
subjects="CR022",
sessions="20201011",
tasks="spontaneous",
acqs="slice03",
)
pwd_path = (
Path(bids_root)
/ "sub-CR022"
/ "ses-20201011"
/ "fusi"
/ "sub-CR022_ses-20201011_task-spontaneous_acq-slice03_pwd.nii.gz"
)
data = cf.load(pwd_path).compute()
data
- time: 751
- z: 1
- y: 114
- x: 80
- 4.464e+03 5.588e+03 5.064e+03 ... 2.977e+04 2.558e+04 2.353e+04
array([[[[ 4463.8 , 5588.404 , 5063.7925, ..., 6008.3438, 5016.807 , 5296.5776], [ 5369.187 , 5347.434 , 5393.3667, ..., 5593.255 , 5330.843 , 6088.181 ], [ 5835.804 , 5867.3096, 6175.863 , ..., 5321.1245, 5456.6743, 4612.9253], ..., [19816.035 , 20495.758 , 17393.748 , ..., 27059.963 , 26307.736 , 23214.475 ], [17802.514 , 17888.11 , 17680.232 , ..., 31637.012 , 29263.018 , 21627.123 ], [21509.184 , 18807.508 , 20343.31 , ..., 32594.61 , 30050.383 , 24083.986 ]]], [[[ 5407.5635, 5091.3843, 4347.751 , ..., 5399.663 , 6108.856 , 5394.4478], [ 4701.354 , 5039.4854, 5073.55 , ..., 5462.995 , 5485.5127, 5088.853 ], [ 6242.7456, 6078.8955, 5453.0264, ..., 5303.4873, ... [18455.709 , 19690.021 , 22065.379 , ..., 29279.365 , 24760.768 , 18865.059 ], [19500.25 , 20546.143 , 19018.531 , ..., 41545.703 , 27996.793 , 21179.195 ]]], [[[ 4493.446 , 5749.9062, 4908.0405, ..., 5116.4893, 5014.6816, 5606.2275], [ 4805.8306, 5201.5073, 6156.0605, ..., 6496.692 , 5214.4478, 6020.7847], [ 4523.9756, 5825.632 , 5338.9243, ..., 4959.6353, 5864.1226, 6266.524 ], ..., [18610.822 , 20753.242 , 18185.65 , ..., 24913.578 , 24254.578 , 22802.502 ], [17924.197 , 21776.996 , 19610.148 , ..., 29756.256 , 21754.053 , 21667.215 ], [22383.992 , 21130.988 , 21290.71 , ..., 29774.988 , 25576.12 , 23529.764 ]]]], shape=(751, 1, 114, 80), dtype=float32) - time(time)float6410.61 10.91 11.21 ... 235.4 235.7
- units :
- s
- volume_acquisition_reference :
- start
- volume_acquisition_duration :
- 0.3
array([ 10.608, 10.908, 11.208, ..., 235.095, 235.395, 235.695], shape=(751,))
- z(z)float641.0
- units :
- mm
- voxdim :
- 0.4000000059604645
array([1.])
- y(y)float642.73 2.778 2.827 ... 8.142 8.19
- units :
- mm
- voxdim :
- 0.04831999912858009
array([2.73008, 2.7784 , 2.82672, 2.87504, 2.92336, 2.97168, 3.02 , 3.06832, 3.11664, 3.16496, 3.21328, 3.2616 , 3.30992, 3.35824, 3.40656, 3.45488, 3.5032 , 3.55152, 3.59984, 3.64816, 3.69648, 3.7448 , 3.79312, 3.84144, 3.88976, 3.93808, 3.9864 , 4.03472, 4.08304, 4.13136, 4.17968, 4.228 , 4.27632, 4.32464, 4.37296, 4.42128, 4.4696 , 4.51792, 4.56624, 4.61456, 4.66288, 4.7112 , 4.75952, 4.80784, 4.85616, 4.90448, 4.9528 , 5.00112, 5.04944, 5.09776, 5.14608, 5.1944 , 5.24272, 5.29104, 5.33936, 5.38768, 5.436 , 5.48432, 5.53264, 5.58096, 5.62928, 5.6776 , 5.72592, 5.77424, 5.82256, 5.87088, 5.9192 , 5.96752, 6.01584, 6.06416, 6.11248, 6.1608 , 6.20912, 6.25744, 6.30576, 6.35408, 6.4024 , 6.45072, 6.49904, 6.54736, 6.59568, 6.644 , 6.69232, 6.74064, 6.78896, 6.83728, 6.8856 , 6.93392, 6.98224, 7.03056, 7.07888, 7.1272 , 7.17552, 7.22384, 7.27216, 7.32048, 7.3688 , 7.41712, 7.46544, 7.51376, 7.56208, 7.6104 , 7.65872, 7.70704, 7.75536, 7.80368, 7.852 , 7.90032, 7.94864, 7.99696, 8.04528, 8.0936 , 8.14192, 8.19024]) - x(x)float64-3.95 -3.85 -3.75 ... 3.85 3.95
- units :
- mm
- voxdim :
- 0.10000000149011612
array([-3.95, -3.85, -3.75, -3.65, -3.55, -3.45, -3.35, -3.25, -3.15, -3.05, -2.95, -2.85, -2.75, -2.65, -2.55, -2.45, -2.35, -2.25, -2.15, -2.05, -1.95, -1.85, -1.75, -1.65, -1.55, -1.45, -1.35, -1.25, -1.15, -1.05, -0.95, -0.85, -0.75, -0.65, -0.55, -0.45, -0.35, -0.25, -0.15, -0.05, 0.05, 0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95, 1.05, 1.15, 1.25, 1.35, 1.45, 1.55, 1.65, 1.75, 1.85, 1.95, 2.05, 2.15, 2.25, 2.35, 2.45, 2.55, 2.65, 2.75, 2.85, 2.95, 3.05, 3.15, 3.25, 3.35, 3.45, 3.55, 3.65, 3.75, 3.85, 3.95])
- qform_code :
- 1
- manufacturer :
- Verasonics
- manufacturers_model_name :
- Vantage 128
- software_version :
- Alan Urban Technology & Consulting (AUTC)
- probe_manufacturer :
- Vermon
- probe_type :
- linear
- probe_model :
- L22-XTech
- probe_central_frequency :
- 15000000.0
- probe_number_of_elements :
- 128
- probe_pitch :
- 0.1
- probe_focal_width :
- 0.4
- probe_focal_depth :
- 8.0
- power_doppler_integration_duration :
- 0.3
- power_doppler_integration_stride :
- 0.3
- clutter_filter_window_duration :
- 0.4
- clutter_filter_window_stride :
- 0.3
- clutter_filters :
- ['highpass:15Hz', 'svd:remove_first_15_components']
- task_name :
- spontaneous
- task_description :
- Spontaneous activity without explicit visual stimulation.
- depth :
- [0.0, 5.46016]
- transmit_frequency :
- 15625000.0
- compound_sampling_frequency :
- 500.0
- plane_wave_angles :
- [-10.0, -7.9, -5.8, -3.6999999999999993, -1.5999999999999996, 0.5000000000000018, 2.6000000000000014, 4.700000000000002, 6.8000000000000025, 8.900000000000002]
- probe_voltage :
- 25.0
- affines :
- {'physical_to_qform': array([[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]])}
Correct for brain motion¶
As in the PCA and
FastICA examples, we first
perform a rigid transformation correction with
register_volumewise to mitigate brain
motion.
/home/runner/work/confusius/confusius/.venv/lib/python3.14/site-packages/rich/live.py:260: UserWarning: install
"ipywidgets" for Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Standardize for NMF¶
NMF requires non-negative inputs, but raw Power Doppler is dominated by each voxel's baseline intensity rather than the temporal fluctuations we want to group. We therefore:
- Z-score each voxel's time series with
standardizeto remove its mean and put voxels on a comparable scale. - Split the standardized signal into separate positive and negative parts.
This keeps the sign information— above-baseline versus below-baseline fluctuations—while still presenting a non-negative matrix to NMF.
z = cf.signal.standardize(data)
data_nmf = xr.concat(
[z.clip(min=0), (-z).clip(min=0)],
dim=xr.IndexVariable("sign", ["pos", "neg"]),
)
Fit temporal NMF¶
NMF wraps the familiar scikit-learn
NMF estimator while preserving fUSI DataArray metadata
and coordinates. With mode="temporal" (the default), it fits on (time, voxels)
and returns:
maps_: non-negative spatial maps. Because we split the input into positive and negative channels, the maps here have shape(component, sign, z, y, x).fit_transform: non-negative time courses of shape(time, component).
nmf = cf.decomposition.NMF(n_components=10, random_state=0, max_iter=500)
signals = nmf.fit_transform(data_nmf)
signals
/home/runner/work/confusius/confusius/.venv/lib/python3.14/site-packages/sklearn/decomposition/_nmf.py:1720: ConvergenceWarning: Maximum number of iterations 500 reached. Increase it to improve convergence.
warnings.warn(
- time: 751
- component: 10
- 0.1658 0.01306 0.0 0.1819 0.0007141 ... 0.04795 0.0 0.3565 0.0 0.06798
array([[0.1657736 , 0.01305686, 0. , ..., 0.17466769, 0.55248916, 0. ], [0.07842413, 0. , 0. , ..., 0.0567409 , 0.521537 , 0. ], [0.02165581, 0. , 0.02380166, ..., 0.01371775, 0.36238965, 0. ], ..., [0.10035805, 0.01997683, 0. , ..., 0.09034111, 0.25914687, 0. ], [0.20754123, 0.0196385 , 0. , ..., 0.11031527, 0.12536858, 0. ], [0.18679054, 0.01474215, 0. , ..., 0.35648656, 0. , 0.06798259]], shape=(751, 10), dtype=float32) - time(time)float6410.61 10.91 11.21 ... 235.4 235.7
- units :
- s
- volume_acquisition_reference :
- start
- volume_acquisition_duration :
- 0.3
array([ 10.608, 10.908, 11.208, ..., 235.095, 235.395, 235.695], shape=(751,))
- component(component)int640 1 2 3 4 5 6 7 8 9
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
- long_name :
- NMF signals
Reconstruction error¶
reconstruction_err_ is the Frobenius norm of
X - WH, where W are the spatial maps and H the time courses. It gives a sense
of how well the chosen number of components explains the standardized data. A
quantitative model-selection procedure is out of scope here, but the trace is useful
when sweeping n_components and looking for diminishing returns.
Spatial maps and time courses¶
Looking at the spatial maps and the associated time courses side by side is a useful
first sanity check. Here each component has two map panels: one for above-baseline
fluctuations (pos) and one for below-baseline fluctuations (neg). Localized,
anatomically plausible structure paired with a clear transient in the time course
tends to reflect a coherent spatiotemporal pattern, while diffuse maps paired with
noisy or drift-like fluctuations often indicate residual motion or physiological
artefacts.
n_show = 10
fig = plt.figure(figsize=(11.5, 12.0), constrained_layout=True)
fig.patch.set_facecolor(bg_color)
gs = fig.add_gridspec(n_show, 2, width_ratios=[1.4, 3])
axes_tc = [fig.add_subplot(gs[i, 1]) for i in range(n_show)]
for ax in axes_tc[1:]:
ax.sharex(axes_tc[0])
for i, comp in enumerate(range(n_show)):
component_map = nmf.maps_.isel(component=comp, drop=True)
vmax = float(component_map.max())
map_gs = gs[i, 0].subgridspec(1, 2, wspace=0.02)
for j, sign in enumerate(["pos", "neg"]):
cf.plotting.plot_volume(
component_map.sel(sign=sign, drop=True),
axes=fig.add_subplot(map_gs[0, j]),
slice_mode="z",
cmap="viridis",
vmin=0,
vmax=vmax,
show_axes=False,
show_colorbar=False,
show_titles=False,
bg_color=bg_color,
)
signals.sel(component=comp).plot(ax=axes_tc[i], lw=1.1)
axes_tc[i].set_title(f"Component {comp + 1}")
axes_tc[i].set_ylabel("Signal")
axes_tc[i].set_xlabel("")
for ax in axes_tc[:-1]:
ax.tick_params(labelbottom=False)
axes_tc[-1].set_xlabel("Time (s)")
_ = fig.suptitle(
"Temporal NMF: positive/negative maps and time courses (first 10 components)",
fontsize=21,
)
Spatial NMF¶
NMF also accepts mode="spatial", which transposes
the data to (voxels, time) before fitting. The output convention is identical to
temporal mode — maps_ still holds the non-negative
spatial maps (here with pos/neg channels) and
fit_transform returns their
non-negative time courses — so the choice between the two modes mirrors the
temporal/spatial choice offered by PCA and
FastICA.
nmf_spatial = cf.decomposition.NMF(
n_components=10, mode="spatial", random_state=0, max_iter=500
)
signals_s = nmf_spatial.fit_transform(data_nmf)
signals_s
/home/runner/work/confusius/confusius/.venv/lib/python3.14/site-packages/sklearn/decomposition/_nmf.py:1720: ConvergenceWarning: Maximum number of iterations 500 reached. Increase it to improve convergence.
warnings.warn(
- time: 751
- component: 10
- -49.06 -108.7 23.6 477.0 273.2 ... 31.01 -59.16 840.9 351.1 -65.79
array([[ -49.060493, -108.72904 , 23.599054, ..., 1360.9792 , 301.0807 , -79.03104 ], [ -46.849873, -102.89783 , 88.44416 , ..., 1054.8505 , 202.54056 , -314.424 ], [ -48.20063 , -89.69527 , 50.060436, ..., 992.90845 , 60.874683, -348.29483 ], ..., [ -52.879208, -147.47987 , 5.829556, ..., 1231.0189 , 251.24892 , -261.63306 ], [ -48.73986 , -141.4787 , 35.300552, ..., 1235.6964 , 480.693 , -212.94316 ], [ -41.16797 , -107.332 , -33.120617, ..., 840.94934 , 351.05264 , -65.78985 ]], shape=(751, 10), dtype=float32) - time(time)float6410.61 10.91 11.21 ... 235.4 235.7
- units :
- s
- volume_acquisition_reference :
- start
- volume_acquisition_duration :
- 0.3
array([ 10.608, 10.908, 11.208, ..., 235.095, 235.395, 235.695], shape=(751,))
- component(component)int640 1 2 3 4 5 6 7 8 9
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
- long_name :
- NMF signals
Spatial maps and time courses¶
As in temporal mode, we inspect the positive and negative spatial maps and their corresponding time courses side by side.
n_show = 10
fig = plt.figure(figsize=(11.5, 12.0), constrained_layout=True)
fig.patch.set_facecolor(bg_color)
gs = fig.add_gridspec(n_show, 2, width_ratios=[1.4, 3])
axes_tc = [fig.add_subplot(gs[i, 1]) for i in range(n_show)]
for ax in axes_tc[1:]:
ax.sharex(axes_tc[0])
for i, comp in enumerate(range(n_show)):
component_map = nmf_spatial.maps_.isel(component=comp, drop=True)
vmax = float(component_map.max())
map_gs = gs[i, 0].subgridspec(1, 2, wspace=0.02)
for j, sign in enumerate(["pos", "neg"]):
cf.plotting.plot_volume(
component_map.sel(sign=sign, drop=True),
axes=fig.add_subplot(map_gs[0, j]),
slice_mode="z",
cmap="viridis",
vmin=0,
vmax=vmax,
show_axes=False,
show_colorbar=False,
show_titles=False,
bg_color=bg_color,
)
signals_s.sel(component=comp).plot(ax=axes_tc[i], lw=1.1)
axes_tc[i].set_title(f"Component {comp + 1}")
axes_tc[i].set_ylabel("Signal")
axes_tc[i].set_xlabel("")
for ax in axes_tc[:-1]:
ax.tick_params(labelbottom=False)
axes_tc[-1].set_xlabel("Time (s)")
_ = fig.suptitle(
"Spatial NMF: positive/negative maps and time courses (first 10 components)",
fontsize=21,
)
Total running time: 290.8 s
Launch in Binder Download .py Download .ipynb
-
Lee, D. D., and Seung, H. S. (1999). "Learning the parts of objects by non-negative matrix factorization". Nature, 401(6755), 788-791. ↩



