from pathlib import Path
from typing import Literal
import numpy as np
import pandas as pd
import plotly.express as px
import ramanspy as rp
import warnings
warnings.filterwarnings("ignore", category=SyntaxWarning)This post grew out of an interview homework and a Kaggle competition. It gives an overview of Raman spectroscopy and then works through a machine learning pipeline for analyzing Raman spectra. The accompanying code is on GitHub at raman-spectra.
Section 1 covers the physics: what inelastic scattering is, why Stokes lines dominate anti-Stokes lines, how Raman and infrared selection rules complement each other, and what a spectrometer is made of.
Section 2 turns to data. The Dig4Bio Raman Transfer Learning Challenge dataset serves as the running example, and an embedded notebook explores it well enough to show why raw spectra from eight different instruments cannot be fed to a model as they are. Section 2.3 then works through the preprocessing pipeline one step at a time — cropping, Whitaker–Hayes cosmic-ray removal, Savitzky–Golay denoising, and asPLS baseline correction — following the implementations in RamanSPy.
Section 3 predicts analyte concentrations from the preprocessed spectra. The model is PLS regression, the standard chemometrics baseline: a linear map from spectra to concentrations built through a low-dimensional latent space. The section derives the algorithm as scikit-learn implements it, since the geometry of scores, loadings, and rotations is what makes the fitted model interpretable.
The codebase also contains SpecBERT, an experimental transformer toolkit that adapts BERT/ViT-style encoders to 1D spectra, with masked spectral modeling for self-supervised pretraining and supervised fine-tuning for regression or classification. It was largely generated with AI assistance as a proof of concept and should be read as a sketch rather than as production software. It is included because it suggests how representation learning might complement classical chemometrics when unlabeled spectra are plentiful and concentration labels are scarce.
Raman spectroscopy: an overview
This section reviews the principle, the instrumentation, and the interpretation of a Raman spectrum. The main source is the Wikipedia article on Raman spectroscopy (Wikipedia contributors 2025).
What Raman spectroscopy measures
Raman spectroscopy is a non-destructive technique for observing vibrational, rotational, and other low-frequency modes in a system. What it returns is a structural fingerprint, precise enough to identify and characterize a molecule. The technique is named after the Indian physicist Sir C. V. Raman, who discovered the effect in 1928 with his student K. S. Krishnan and received the Nobel Prize in Physics in 1930.
The underlying phenomenon is the inelastic scattering of monochromatic light, in practice a laser. When light interacts with a molecule, almost all of it scatters at the incident energy and wavelength; this is Rayleigh scattering, and it carries no chemical information. About one photon in ten million scatters at a different energy instead. That is the Raman effect, and the energy difference is where the chemistry is.
Figure 1 shows what the result looks like.
The physical principle
Molecular vibrations and degrees of freedom
A molecule of \(N\) atoms has \(3N\) degrees of freedom, since each atom can move independently along \(x\), \(y\), and \(z\). Not all of these are vibrations. Three of them describe translation of the molecule as a rigid body through space. Another two or three describe rotation about its center of mass: two for a linear molecule, three for a non-linear one. Whatever remains is internal vibration, the stretching, bending, twisting, and rocking of the bonds themselves:
\[ \text{non-linear: } 3N - 6 \text{ modes}, \qquad \text{linear: } 3N - 5 \text{ modes}. \]
This count is worth doing before looking at a spectrum, because it bounds the number of fundamental bands one can expect to find.
Vibrational energy
Vibrational energy is quantized. A bond cannot hold an arbitrary amount of it, only discrete levels, like the rungs of a ladder. The quantum harmonic oscillator is the standard first approximation, with levels
\[ E_n = h\left(n + \tfrac{1}{2}\right)\nu , \]
where \(n = 0, 1, 2, \dots\) is the quantum number, \(h\) is Planck’s constant, and \(\nu\) is the natural frequency of the vibration, which depends on the stiffness of the bond and on the masses of the atoms it joins.
Raman spectroscopy never measures \(E_n\) itself. It measures the energy required to jump between levels, in practice the transition from \(n=0\) to \(n=1\). That difference \(\Delta E = h\nu\) is what appears as a peak, reported as a Raman shift in wavenumbers (\(\text{cm}^{-1}\)).
Raman scattering
A laser photon striking a molecule pushes it into an unstable, extremely short-lived virtual state, from which it immediately relaxes by emitting a new photon. Three things can happen:
- Rayleigh scattering (elastic). The molecule returns to the vibrational level it started from, and the emitted photon has exactly the energy of the incident one. This is by far the most common outcome and carries no chemical information.
- Stokes scattering (inelastic). The molecule starts in the ground state \(n=0\) and relaxes to a higher level \(n=1\). Having absorbed the vibrational quantum, the emitted photon comes out with \(h\nu\) less energy than it went in with. This is the signal normally measured.
- Anti-Stokes scattering (inelastic). The molecule starts in an already excited state \(n=1\) and relaxes to the ground state, so the emitted photon leaves with \(h\nu\) more energy than it arrived with.
At room temperature the Boltzmann distribution puts the vast majority of molecules in the ground state, which is why Stokes peaks are always considerably more intense than anti-Stokes peaks.
Raman-active and IR-active vibrations
Not every vibration produces a Raman signal, and the rule that decides is exactly what separates Raman from infrared spectroscopy. A vibration is Raman active only if it changes the polarizability of the molecule, that is, how readily the electron cloud deforms under an external electric field such as the laser’s. A vibration is IR active only if it changes the dipole moment.
The two rules are largely complementary. Symmetric vibrations such as C–C or S–S stretches usually produce a large change in polarizability, so they are strong in Raman and weak or absent in IR. Asymmetric vibrations of polar bonds such as C=O produce a large change in dipole moment, so they are strong in IR and often weak in Raman. For a molecule with a center of symmetry, such as CO₂, the rule of mutual exclusion makes the separation strict: no vibration can be both IR and Raman active.
The table below summarizes the difference between the two quantities.
| Feature | Dipole moment (\(\mu\)) | Polarizability (\(\alpha\)) |
|---|---|---|
| Nature | Intrinsic, permanent property | Induced, response property |
| Origin | Unequal sharing of electrons between atoms of different electronegativity | Deformation of the electron cloud by an external field |
| Existence | Present even with no external field | Describes only the potential to deform; the deformation itself requires a field |
| Example | H₂O is polar and has a large dipole moment | Benzene is non-polar but highly polarizable |
| Selection rule | A change during the vibration makes it IR active | A change during the vibration makes it Raman active |
Fluorescence and Raman scattering
Fluorescence is the other thing that happens when light meets matter, and in practice it is the main obstacle to a clean Raman measurement. The two processes differ in mechanism and in everything that follows from it.
| Feature | Raman scattering | Fluorescence |
|---|---|---|
| Mechanism | Scattering through a virtual state | Absorption and re-emission through real electronic states |
| Timescale | Essentially instantaneous (femtoseconds) | Delayed by the fluorescence lifetime (nanoseconds) |
| Energy shift | Small, discrete shifts set by vibrational energies | One large, broad shift set by the gap between electronic states |
| Information | A sharp fingerprint of specific vibrations | A broad band indicating that a fluorophore is present |
| Efficiency | About 1 photon in \(10^7\), hence a very weak signal | Can exceed 90%, hence a very strong signal |
A weak, sharp signal sitting on top of a strong, broad one is the situation that the baseline correction of Section 2.3 exists to handle.
Instrumentation
A Raman spectrometer is built around one problem: isolating the extremely weak Raman signal from the overwhelming Rayleigh scattering that accompanies it. Its essential components are
- a laser, providing a stable monochromatic source, typically visible (532 nm, 633 nm) or near-infrared (785 nm, 1064 nm), the latter chosen to reduce fluorescence;
- illumination and collection optics, lenses or microscope objectives that focus the beam onto the sample and gather the scattered light, down to areas of about one micron in micro-Raman spectroscopy;
- an optical filter, notch or edge, the critical component, which rejects the Rayleigh line while passing the Stokes and anti-Stokes light;
- a dispersive element, a diffraction grating that separates the signal into its constituent wavelengths;
- a detector, almost always a charge-coupled device (CCD), sensitive enough to record the whole spectrum at once.
The CCD is also the source of one of the artifacts treated below: a cosmic ray hitting the detector during acquisition leaves a spike that looks like a very narrow Raman peak.
Reading a Raman spectrum
The horizontal axis of a spectrum such as Figure 1 is the Raman shift in \(\text{cm}^{-1}\), which corresponds to the vibrational energy of the bonds. Because it is a shift rather than an absolute wavelength, it does not depend on the laser used, which is what makes spectra comparable across instruments. The vertical axis is intensity in arbitrary units, proportional both to the concentration of the molecule and to how Raman active the vibration in question is. That proportionality to concentration is the entire basis for the regression models of Section 3.
Each peak corresponds to a specific vibration, and the spectrum splits into two regions of different character. The fingerprint region, roughly 400–1800 \(\text{cm}^{-1}\), is dense with bending and stretching modes whose combined pattern is unique to each molecule. The functional group region, roughly 1800–4000 \(\text{cm}^{-1}\), is sparser and more directly interpretable, containing C–H stretches around 2800–3100 \(\text{cm}^{-1}\), C≡C triple bonds around 2100–2300 \(\text{cm}^{-1}\), and O–H stretches around 3200–3600 \(\text{cm}^{-1}\).
Advantages and limitations
Advantages
Raman spectroscopy uses low-power light and generally leaves the sample intact. It needs almost no sample preparation and works on solids, liquids, and gases, often straight through a glass or plastic container. Water is a weak Raman scatterer, which makes the technique well suited to biological and aqueous samples and is a decisive advantage over infrared spectroscopy. Micro-Raman resolves chemical information at the micron scale, the fingerprint it produces is sharp enough for unambiguous identification, and coupling to fiber optics allows remote and in-situ monitoring.
Limitations
The effect is inherently inefficient, so acquisition can be slow. Fluorescence is the most serious difficulty: it is orders of magnitude stronger than the Raman signal and can bury it entirely, which is why near-infrared lasers around 785 nm are so common. High laser intensity can heat or burn a sensitive sample. And since the technique probes molecular bonds, it is not suited to elemental analysis of metals.
Exploration and preprocessing
The data from the interview homework is not public, so the dataset from the Kaggle competition (Hassfurther 2025) takes its place here. Both the dataset and the competition come from the article (Lange et al. 2025), which compares machine learning methods on Raman spectra collected on eight different spectrometers.
The aim below is not to reproduce the extensive analysis of that article, but to work through the simpler approaches implemented for the homework. All spectral processing uses RamanSPy (Georgiev et al. 2024).
The notebook that follows explores the dataset, applies the preprocessing pipeline step by step, and compares it against a background-subtraction alternative. The algorithms it calls are derived afterwards in Section 2.3.
Preprocessing algorithms
Raw Raman spectra arrive with artifacts and noise that would defeat any downstream analysis: a fluorescence background far larger than the signal, cosmic-ray spikes indistinguishable from narrow peaks, random detector noise, and edge regions where the instrument response is unreliable. The standard pipeline removes them in a fixed order — crop, despike, denoise, correct the baseline — and the four subsections below derive each step as RamanSPy implements it (Georgiev et al. 2024).
Spectral cropping
Cropping restricts the spectrum to the region worth analyzing. Given a wavenumber axis \(w\) and a spectrum \(s\), it is a boolean mask applied to both:
\[ w_{\text{cropped}} = w\,[\,w_{\text{min}} \leq w \leq w_{\text{max}}\,], \qquad s_{\text{cropped}} = s\,[\,w_{\text{min}} \leq w \leq w_{\text{max}}\,] . \]
The operation is \(O(n)\) in the spectrum length and does three useful things at once: it discards noisy edge regions, it concentrates the later steps on the informative part of the signal, and it shrinks the data.
The choice of range matters more than the triviality of the operation suggests. The notebooks crop to 300–1942 \(\text{cm}^{-1}\), following (Lange et al. 2025): this is the largest range common to all eight instruments in the dataset — 300 to 1941 \(\text{cm}^{-1}\), going by the files themselves — so cropping to it also makes the spectra mutually comparable. It retains the glucose bands around 800–1150 \(\text{cm}^{-1}\), the acetate band near 920 \(\text{cm}^{-1}\), and the strong MgSO₄ band near 980 \(\text{cm}^{-1}\), while removing the Rayleigh tail below roughly 450 \(\text{cm}^{-1}\) and the O–H stretch of water above 3000 \(\text{cm}^{-1}\), which would otherwise dominate everything else.
Cosmic-ray removal (Whitaker–Hayes)
A cosmic ray striking the CCD during acquisition produces a sharp intensity spike that is easily mistaken for a genuine Raman peak. The Whitaker–Hayes algorithm (Whitaker and Hayes 2018) detects and removes these.
Detection works on the first differences of the spectrum,
\[ \Delta s_i = s_{i+1} - s_i , \]
because differencing amplifies abrupt jumps while flattening the gradual variation of real bands, which makes a spike far easier to see than it is in the raw intensities. Spikes are then flagged by a modified z-score, which replaces the standard deviation by the median absolute deviation
\[ \text{MAD}(x) = \text{median}\big(|x_i - \text{median}(x)|\big), \qquad z_i = 0.6745\,\frac{x_i - \text{median}(x)}{\text{MAD}(x)} , \]
the constant \(0.6745\) being what makes the MAD consistent with the standard deviation for normal data. Median-based statistics matter here: the outliers being hunted are exactly the kind of points that would corrupt a mean-and-variance estimate of the noise level.
Every position with \(|z_{\Delta s_i}| > \tau\) is declared a spike and replaced by the mean of its non-spike neighbors within a kernel of half-width \(k\),
\[ s'_i = \begin{cases} \operatorname{mean}\big(s_j : j \in N(i),\ \text{spike}_j = \text{False}\big) & \text{if } \text{spike}_i = \text{True},\\[2pt] s_i & \text{otherwise}, \end{cases} \]
with \(N(i) = \{\max(0, i-k), \dots, \min(n-1, i+k)\}\). Excluding the neighbors that are themselves spikes is what lets the procedure handle several adjacent contaminated points, and replacing by a local mean rather than by interpolation over a wide window leaves the surrounding peak shapes untouched. RamanSPy defaults to \(\tau = 8\) and kernel_size = 3, which the notebooks keep.
Denoising (Savitzky–Golay)
Random noise obscures weak bands and, more insidiously, produces spurious local maxima that peak-finding routines will happily report. The Savitzky–Golay filter (Savitzky and Golay 1964) suppresses it by local polynomial regression, which preserves peak position and height far better than a moving average does.
For every point of the spectrum, the filter takes a window of window_length points centered on it, fits a polynomial of degree polyorder by least squares,
\[ P(x) = a_0 + a_1 x + \dots + a_p x^p, \qquad \min_{a} \sum_{j=i-m}^{i+m} \big[y_j - P(x_j)\big]^2 , \]
where \(m = (\texttt{window\_length} - 1)/2\), and replaces the point by the fitted value \(y'_i = P(x_i)\). Since the least-squares solution is linear in the data, the whole operation collapses into a convolution with coefficients that depend only on window_length and polyorder, never on the spectrum itself, so the filter costs \(O(n)\) and the coefficients can be computed once.
The reason it works is that averaging over a window suppresses noise while the polynomial retains local curvature. A moving average is the special case \(p = 0\), and it is precisely the flat-fit assumption that flattens narrow peaks; a cubic fit follows the shape of a band through the window instead. The same coefficients also yield smoothed derivatives at no extra cost, which is convenient for peak detection.
window_length must be odd and strictly greater than polyorder; RamanSPy requires both explicitly rather than supplying defaults. The notebooks use window_length = 9 and polyorder = 3. Shorter windows preserve narrow peaks better but remove less noise, longer ones do the reverse, and beyond about 15 points a genuine band starts to broaden visibly.
Baseline correction (asPLS)
The fluorescence background, together with sample-holder and optical contributions, adds a slowly varying baseline that is often larger than the peaks sitting on it. Removing it is the last and most delicate preprocessing step, because a baseline estimator flexible enough to follow the background is also flexible enough to climb into the peaks and eat them.
Every method in this family solves a penalized least-squares problem: find a baseline \(z\) minimizing a compromise between fidelity to the data and smoothness,
\[ Q(z) = \sum_i w_i (y_i - z_i)^2 \; + \; \lambda \sum_j \alpha_j \big(\Delta^d z_j\big)^2 , \]
where \(y\) is the observed spectrum, \(\Delta^d\) is the \(d\)-th order difference operator, and \(\lambda\) sets the global stiffness. With \(d = 2\),
\[ \Delta^2 z = [\,z_2 - 2z_1 + z_0,\ z_3 - 2z_2 + z_1,\ \dots\,] , \]
so the penalty charges for curvature and the minimizer is pulled toward a smooth curve. What distinguishes the members of the family is how the fidelity weights \(w_i\) and the penalty weights \(\alpha_j\) are chosen. RamanSPy exposes several of them through pybaselines — AsLS (Eilers and Boelens 2005), airPLS (Zhang et al. 2010), arPLS, asPLS — and the pipeline here uses asPLS (Zhang et al. 2020).
The original asymmetric least squares method (Eilers and Boelens 2005) fixes \(\alpha_j \equiv 1\) and splits the fidelity weights in two,
\[ w_i = \begin{cases} p & \text{if } y_i > z_i,\\ 1-p & \text{if } y_i \leq z_i, \end{cases} \qquad p \ll 0.5 \ \ (\text{typically } 0.01). \]
Since peaks lie above the baseline, they receive almost no weight, and the fitted curve is pushed underneath them. This is the mechanism that keeps the baseline out of the peaks, and asPLS keeps its spirit while replacing both weightings.
Write \(r = y - z\) for the residual at the current iterate. The fidelity weights become logistic rather than binary,
\[ w_i = \frac{1}{1 + \exp\!\big(\tfrac{k}{\sigma_-}(r_i - \sigma_-)\big)} , \]
where \(\sigma_-\) is the standard deviation of the negative residuals and \(k\) is an asymmetry coefficient. This is a smoothed version of the AsLS split: points far above the baseline get weights near \(0\), points below it get weights near \(1\), and the transition between the two happens over a width set by \(\sigma_-\). Estimating that scale from the negative residuals only is what makes it a measure of background noise rather than of peak height, so the filter adapts to how noisy the spectrum actually is instead of relying on a fixed threshold. The asPLS paper uses \(k=2\); pybaselines, and therefore RamanSPy, defaults to \(k = 0.5\), which its authors report as both closer to the published results and better behaved on noisy data.
The adaptive part of the method is the penalty weight, recomputed at each iteration as the normalized residual magnitude
\[ \alpha_i = \frac{|r_i|}{\max_j |r_j|} \in [0, 1] . \]
Note the direction, which is easy to get backwards. Under a peak the residual is large, so \(\alpha_i \approx 1\) and the full penalty \(\lambda\) applies: the baseline is held stiff exactly where it would otherwise be tempted to rise into the band. Where the baseline already tracks the data, the residual is small, \(\alpha_i\) is small, the penalty is relaxed, and the curve is free to follow the local curvature of the background. A single global \(\lambda\) has to be either too stiff under peaks or too rigid in between; letting \(\alpha\) vary point by point removes that compromise, and it is what the “adaptive smoothness” in the name refers to.
The two weightings are updated alternately with the baseline solve until the weights stop changing, at most a hundred times. RamanSPy inherits the pybaselines defaults \(\lambda = 10^5\), diff_order = 2, and tol = 1e-3, which the notebooks keep.
The complete pipeline
The order of operations is not arbitrary. Cropping comes first so that the later steps never see the noisy edges. Despiking comes before smoothing, because a Savitzky–Golay filter applied to a spike does not remove it, it merely spreads it over the window and makes it harder to detect. Denoising comes before baseline correction, since the residual-based weighting of asPLS reads the noise level off the spectrum and behaves better once that noise is reduced. Normalization, when used, comes last, after the baseline has been removed and intensities are finally comparable.
The pipeline used in both notebooks is therefore
- Cropping to 300–1942 \(\text{cm}^{-1}\);
- Whitaker–Hayes despiking, with
kernel_size = 3andthreshold = 8; - Savitzky–Golay smoothing, with
window_length = 9andpolyorder = 3; - asPLS baseline correction, with \(\lambda = 10^5\) and
diff_order = 2; - MinMax normalization, for the modeling notebook only.
Whatever parameters are chosen, they must stay fixed across every spectrum in a study. Preprocessing is part of the model, and varying it per sample introduces exactly the kind of sample-dependent transformation that a calibration is supposed to be free of.
Machine learning approaches
The task is to predict the concentrations of the three analytes — glucose, sodium acetate, and magnesium sulfate — from a preprocessed spectrum. Models are fitted one instrument at a time, since spectral response and calibration differ enough between the eight spectrometers that a single pooled model would be solving a harder problem than the one at hand.
One preprocessing decision is properly a modeling decision and is worth stating here rather than leaving it in the notebook. Peak height is proportional to concentration, so normalizing a spectrum destroys precisely the information a regression needs; but leaving intensities unnormalized also leaves in the instrumental variation — laser power, integration time, focus, detector response — that has nothing to do with chemistry, and on this dataset it leaves intensities reaching values around 350 in arbitrary units. Area-under-the-curve scaling is the worst of both worlds for regression, since forcing every spectrum to unit area erases concentration outright. Internal standard normalization, dividing by a stable reference band, is the principled answer, but the dataset offers no suitable reference peak. The notebook settles on MinMax scaling as a compromise, with the caveat that it ties the scale of each spectrum to whichever point happens to be its maximum. This is discussed at length in the notebook below.
The model itself is PLS regression, derived in Section 3.3.
PLS regression
The problem
A preprocessed spectrum has as many features as it has wavenumbers, and the dataset has far fewer samples than that. Worse, adjacent wavenumbers are almost perfectly correlated, so the design matrix is severely rank-deficient and ordinary least squares is not merely unstable but undefined. Partial least squares is the standard chemometrics answer, and the baseline against which (Lange et al. 2025) measures everything else: rather than regressing on the wavenumbers, it builds a small number of latent components chosen to have maximal covariance with the targets, and regresses on those.
Formally, given centered data matrices \(X \in \mathbb{R}^{n \times p}\) and \(Y \in \mathbb{R}^{n \times q}\) and a number of components \(K\), we seek a low-dimensional representation of \(X\) that both reconstructs \(X\) and predicts \(Y\). Here \(n\) is the number of spectra, \(p\) the number of wavenumbers, \(q = 3\) the number of analytes, and \(K\) is chosen by cross-validation.
Two variants are worth distinguishing, because scikit-learn implements both and they are easily confused. PLSCanonical, also known as PLSW2A, treats \(X\) and \(Y\) symmetrically and is a decomposition method. PLSRegression, the one used in the notebook and derived below, breaks that symmetry deliberately in order to predict \(Y\) from \(X\). The difference reduces to two changes in the algorithm, both appearing in step (c) and step (d) below: the \(Y\)-weights are not normalized, and \(Y\) is regressed and deflated on the \(X\)-scores rather than on its own.
The algorithm
Start from \(X_0 = X\) and \(Y_0 = Y\), both column-centered and, with scikit-learn’s default scale=True, scaled to unit variance. For each component \(k = 1, \dots, K\):
(a) Weights. Form the cross-covariance matrix \(C_k = X_{k-1}^\top Y_{k-1}\) and take its leading singular pair,
\[ (w_k, c_k) = \arg\max_{\|w\| = 1} w^\top C_k c , \]
with \(w_k \in \mathbb{R}^p\) constrained to unit norm and \(c_k \in \mathbb{R}^q\) left unnormalized. These are the directions in the two spaces whose projections covary most. They can be obtained by a full SVD of \(C_k\) or, as scikit-learn does by default, by power iteration (NIPALS).
(b) Scores. Project the data onto the weights,
\[ t_k = X_{k-1} w_k \in \mathbb{R}^n , \qquad u_k = \frac{Y_{k-1} c_k}{c_k^\top c_k} \in \mathbb{R}^n . \]
The scores are the coordinates of the samples in the latent space. The \(X\)-score \(t_k\) does all the work below; \(u_k\) is exposed by scikit-learn as y_scores_ but takes no part in either the deflation or the prediction, which is the whole point of regression mode.
(c) Loadings. Regress the original variables on the \(X\)-score:
\[ p_k = \frac{X_{k-1}^\top t_k}{t_k^\top t_k} \in \mathbb{R}^p , \qquad q_k = \frac{Y_{k-1}^\top t_k}{t_k^\top t_k} \in \mathbb{R}^q . \]
Both loadings use \(t_k\). This is the asymmetry that defines PLSRegression: PLSCanonical would compute \(q_k = Y_{k-1}^\top u_k / (u_k^\top u_k)\), treating each block on its own terms. Here \(Y\) is described entirely through the latent variables extracted from \(X\), which is what a predictive model needs.
(d) Deflation. Subtract the rank-one structure just extracted,
\[ X_k = X_{k-1} - t_k p_k^\top , \qquad Y_k = Y_{k-1} - t_k q_k^\top . \]
Again both use \(t_k\). Deflation is what guarantees that the next component finds covariance structure that the previous ones did not already explain.
Matrix decompositions
Collecting the \(K\) components into matrices gives two low-rank approximations,
\[ X \approx T P^\top , \qquad Y \approx T Q^\top , \]
with \(T = [t_1, \dots, t_K] \in \mathbb{R}^{n \times K}\), \(P = [p_1, \dots, p_K] \in \mathbb{R}^{p \times K}\), and \(Q = [q_1, \dots, q_K] \in \mathbb{R}^{q \times K}\).
The same \(T\) appears in both. Spectra and concentrations are expressed in one shared coordinate system, and prediction amounts to reading a sample’s coordinates off its spectrum and evaluating the second decomposition. This is what makes PLS loadings chemically interpretable: a column of \(P\) shows which spectral regions define a component, and the corresponding column of \(Q\) shows what that component says about each analyte.
The rotation matrix
During fitting, the scores are computed sequentially on deflated data. For a new spectrum we need a single matrix \(R_x \in \mathbb{R}^{p \times K}\) with \(T = X R_x\), so that \(T_{\text{new}} = X_{\text{new}} R_x\) without replaying the deflation.
The obvious guess \(T = XW\) is wrong for every component after the first, because \(w_k\) was computed on \(X_{k-1}\) while \(X\) is undeflated; deflation breaks the direct correspondence between weights and scores. The fix follows from the decomposition. Multiplying \(X \approx TP^\top\) on the right by \(W\) gives \(XW \approx TP^\top W\), and since \(P^\top W \in \mathbb{R}^{K \times K}\) is invertible,
\[ R_x = W\big(P^\top W\big)^{-1} , \qquad T = X R_x . \]
The dimensions run
\[ X \in \mathbb{R}^{n \times p}, \quad W \in \mathbb{R}^{p \times K}, \quad P \in \mathbb{R}^{p \times K}, \quad P^\top W \in \mathbb{R}^{K \times K}, \quad R_x \in \mathbb{R}^{p \times K}, \quad T \in \mathbb{R}^{n \times K} . \]
Read it as a correction: \(W\) gives raw projections computed on deflated data, \((P^\top W)^{-1}\) compensates for the interference between successive components, and the product is the equivalent direct projection from the original space. Geometrically, the rotation matrix folds the entire sequential deflation back into a single linear map. scikit-learn stores it as x_rotations_, alongside the analogous \(R_y = C(Q^\top C)^{-1}\) in y_rotations_.
Prediction
Everything needed is now in place. Scores come from the spectrum through \(T = X R_x\), and concentrations come from the scores through \(Y \approx T Q^\top\), so
\[ \hat{Y} = X R_x Q^\top , \qquad B = R_x Q^\top \in \mathbb{R}^{p \times q} , \]
and the fitted model is the single linear map \(\hat{Y} = XB\). The pipeline is
\[ X \;\xrightarrow{\;R_x\;}\; T \;\xrightarrow{\;Q^\top\;}\; \hat{Y} . \]
There is no separate regression between the latent spaces. Because step (c) already fitted \(Y\) against \(t\), the \(Y\)-loadings are the latent regression coefficients. PLSCanonical, having fitted \(Y\) against \(u\), needs an extra least-squares step \(U \approx TG\) and predicts through \(R_x G Q^\top\) instead — a third stage that regression mode makes unnecessary.
In scikit-learn, \(B\) is stored in coef_ with the standardization folded in and transposed to shape \((q, p)\): coef_ \(= (R_x Q^\top \sigma_y)^\top / \sigma_x\), with intercept_ \(= \bar{y}\), so that predict evaluates \((X - \bar{x})\,\texttt{coef\_}^\top + \bar{y}\).
Summary
| Concept | Symbol | Dimension | Meaning |
|---|---|---|---|
| Weights | \(W, C\) | \(p \times K\), \(q \times K\) | Directions of maximal shared covariance |
| Scores | \(T\) | \(n \times K\) | Coordinates of the samples in the latent space |
| Loadings | \(P, Q\) | \(p \times K\), \(q \times K\) | Reconstruction of \(X\) and of \(Y\) from those coordinates |
| Rotation | \(R_x\) | \(p \times K\) | Direct projection from spectra to scores |
| Coefficients | \(B\) | \(p \times q\) | The fitted linear map \(X \to Y\) |
\[ T = X R_x , \qquad X \approx T P^\top , \quad Y \approx T Q^\top , \qquad \hat{Y} = XB \ \text{ with }\ B = R_x Q^\top . \]
The one hyperparameter is \(K\). Too few components underfit, too many start fitting noise, and the notebook selects it by scanning \(K = 1, \dots, 20\) and minimizing the cross-validated RMSE. The folds are built with GroupKFold on the sample index so that replicate spectra of the same sample never straddle the train/validation split, which would otherwise leak the answer and produce an optimistic estimate of performance on genuinely new samples.
Conclusion
Most of the work in this post is preprocessing, and that is the honest proportion. The physics of Section 1 explains why: the signal is one photon in \(10^7\), riding on a fluorescence background that can be larger than it is, on a detector that cosmic rays hit. Cropping, despiking, denoising, and baseline correction are not data hygiene applied before the interesting part — they are what turns a measurement into something a linear model can be fitted to at all.
The model that follows is deliberately simple, and the results on the anton_532 instrument show both what it buys and where it stops. Magnesium sulfate is predicted well (\(R^2 \approx 0.81\)), glucose moderately (\(R^2 \approx 0.39\)), and sodium acetate not at all (\(R^2 \approx 0.02\)). The pattern tracks the spectroscopy rather than the machine learning: MgSO₄ has a strong, isolated band near 980 \(\text{cm}^{-1}\), whereas the acetate band near 920 \(\text{cm}^{-1}\) is weaker and overlaps its neighbors.
Fitting one PLS model per analyte rather than a single multi-output model changes less than one might expect, and the little it changes is concentrated in one place. Glucose and magnesium sulfate move by a few thousandths of \(R^2\), in opposite directions. What separate models fix is acetate, which the joint model predicted at \(R^2 = -0.17\) — worse than the sample mean. Made to share twelve components with two analytes that dominate the covariance, the joint model actively degraded its weakest target; given four components of its own, it merely fails to predict it. The other argument for fitting separately is interpretability, since each model then carries loadings that can be read directly against known band positions instead of mixing three chemistries into one shared set of components.
Two limitations are worth naming. MinMax normalization was chosen for want of an internal standard, and it couples the scale of every spectrum to a single point; a proper reference band, or a normalization that preserves the height-to-concentration relation, would be the first thing to revisit. And the models here are fitted per instrument, which sidesteps rather than solves the transfer problem that the original dataset was built to pose. Transfer across spectrometers is where the SpecBERT experiment in the accompanying codebase is aimed: pretraining on unlabeled spectra from many instruments is a plausible way to learn representations that survive the change of device, in a setting where spectra are cheap and concentration labels are not.
References
Citation
@online{brosse2026,
author = {Brosse, Nicolas},
title = {Analyzing {Raman} Spectra with Machine Learning},
date = {2026-01-18},
url = {https://nbrosse.github.io/posts/raman/raman.html},
langid = {en}
}

















