The NormData class#

A key component of the PCNtoolkit is the NormData object. It is a container for the data that will be used to fit the normative model. The NormData object keeps track of the all the dimensions of your data, the features and response variables, batch effects, preprocessing steps, and more.

import copy

import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split

from pcntoolkit import NormData

Creating a NormData object#

There are currently two easy ways to create a NormData object. 1. Load from a pandas dataframe 2. Load from numpy arrays

Here are examples of both.

# Creating a NormData object from a pandas dataframe

# Download an example dataset:
data = pd.read_csv(
    "https://raw.githubusercontent.com/predictive-clinical-neuroscience/PCNtoolkit-demo/refs/heads/main/data/fcon1000.csv"
)

# specify the column names to use
covariates = ["age"]
batch_effects = ["sex", "site"]
response_vars = ["WM-hypointensities", "Left-Lateral-Ventricle", "Brain-Stem"]

# create a NormData object
norm_data = NormData.from_dataframe(
    name="fcon1000",
    dataframe=data,
    covariates=covariates,
    batch_effects=batch_effects,
    response_vars=response_vars,
    remove_outliers=True,
    z_threshold=10,
)
norm_data.coords
Process: 75157 - 2025-11-20 13:13:40 - Removed 2 outliers for WM-hypointensities
Process: 75157 - 2025-11-20 13:13:40 - Removed 2 outliers
Process: 75157 - 2025-11-20 13:13:40 - Dataset "fcon1000" created.
    - 1076 observations
    - 1076 unique subjects
    - 1 covariates
    - 3 response variables
    - 2 batch effects:
            sex (2)
    site (23)
Coordinates:
  * observations       (observations) int64 9kB 0 1 2 3 ... 1072 1073 1074 1075
  * response_vars      (response_vars) <U22 264B 'WM-hypointensities' ... 'Br...
  * covariates         (covariates) <U3 12B 'age'
  * batch_effect_dims  (batch_effect_dims) <U4 32B 'sex' 'site'
# Creating a NormData object from numpy arrays
import numpy as np

from pcntoolkit import NormData

# synthesize some data
X = np.random.randn(100, 10)
Y = np.random.randn(100, 10)
batch_effects = np.random.randint(0, 2, 100)[:,None]
subject_ids = np.arange(100)

# Create a NormData object
np_norm_data = NormData.from_ndarrays("fcon1000", X=X, Y=Y, batch_effects=batch_effects, subject_ids=subject_ids)
np_norm_data.coords
Process: 75157 - 2025-11-20 13:18:52 - Dataset "fcon1000" created.
    - 100 observations
    - 100 unique subjects
    - 10 covariates
    - 10 response variables
    - 1 batch effects:
            batch_effect_0 (2)
Coordinates:
  * observations       (observations) int64 800B 0 1 2 3 4 5 ... 95 96 97 98 99
  * response_vars      (response_vars) <U14 560B 'response_var_0' ... 'respon...
  * covariates         (covariates) <U11 440B 'covariate_0' ... 'covariate_9'
  * batch_effect_dims  (batch_effect_dims) <U14 56B 'batch_effect_0'

As you can see, it is very simple to create a NormData object.

There is an important difference though: the coordinates of the NormData object that was created with from_dataframe have the name of the column in the dataframe, but the from_ndarrays method creates coordinates with generic names. This is why the from_dataframe method is favorable.

Casting back to a pandas dataframe#

The NormData object can be cast back to a pandas dataframe using the to_dataframe method. This will return a pandas dataframe with a columnar multi-index.

df = norm_data.to_dataframe()
df.head()
X Y batch_effects subject_ids
age Brain-Stem Left-Lateral-Ventricle WM-hypointensities sex site subject_ids
0 25.63 20663.2 4049.4 1686.7 1 AnnArbor_a 0
1 18.34 19954.0 9312.6 1371.1 1 AnnArbor_a 1
2 29.20 21645.2 8972.6 1414.8 1 AnnArbor_a 2
3 31.39 20790.6 6798.6 1830.6 1 AnnArbor_a 3
4 13.58 17692.6 6112.5 1642.4 1 AnnArbor_a 4

Inspecting the NormData#

So let’s go over the attributes of the NormData object. Because it is a subclass of xarray.Dataset, it has all the attributes of a xarray.Dataset, but it has some additional attributes that are specific to normative modelling.

The data variables#

The data variables of the NormData object are: - X: The covariates - Y: The response variables - batch_effects: The batch effects - subjects: The subject ids

And all these data variables are xarray.DataArrays, with corresponding dimensions, stored in the data_vars attribute of the NormData object.

norm_data.data_vars
Data variables:
    subject_ids    (observations) int64 9kB 0 1 2 3 4 ... 1072 1073 1074 1075
    Y              (observations, response_vars) float64 26kB 1.687e+03 ... 1...
    X              (observations, covariates) float64 9kB 25.63 18.34 ... 23.0
    batch_effects  (observations, batch_effect_dims) <U17 146kB '1' ... 'Sain...

The coordinates#

Because it is a subclass of xarray.Dataset, the NormData object also holds all the coordinates of the data, found under the coords attribute.

The coordinates are: - observations: The index of the observations - response_vars: The names of the response variables - covariates: The names of the covariates - batch_effect_dims: The names of the batch effect dimensions

norm_data.coords
Coordinates:
  * observations       (observations) int64 9kB 0 1 2 3 ... 1072 1073 1074 1075
  * response_vars      (response_vars) <U22 264B 'WM-hypointensities' ... 'Br...
  * covariates         (covariates) <U3 12B 'age'
  * batch_effect_dims  (batch_effect_dims) <U4 32B 'sex' 'site'

Indexing using the coordinates#

Xarrays powerful indexing methods can also be used on NormData.

Selecting a response variable#

For example, to select the data for a specific response variable, you can use the response_vars coordinate:

norm_data.sel(response_vars="WM-hypointensities")

This will return a new NormData object with only the data for the response variable “WM-hypointensities”.

norm_data.sel(response_vars="WM-hypointensities")
<xarray.NormData> Size: 181kB
Dimensions:            (observations: 1076, covariates: 1, batch_effect_dims: 2)
Coordinates:
  * observations       (observations) int64 9kB 0 1 2 3 ... 1072 1073 1074 1075
    response_vars      <U22 88B 'WM-hypointensities'
  * covariates         (covariates) <U3 12B 'age'
  * batch_effect_dims  (batch_effect_dims) <U4 32B 'sex' 'site'
Data variables:
    subject_ids        (observations) int64 9kB 0 1 2 3 ... 1072 1073 1074 1075
    Y                  (observations) float64 9kB 1.687e+03 1.371e+03 ... 509.1
    X                  (observations, covariates) float64 9kB 25.63 ... 23.0
    batch_effects      (observations, batch_effect_dims) <U17 146kB '1' ... '...
Attributes:
    real_ids:                       False
    is_scaled:                      False
    name:                           fcon1000
    unique_batch_effects:           {np.str_('sex'): [np.str_('0'), np.str_('...
    batch_effect_counts:            defaultdict(<function NormData.register_b...
    covariate_ranges:               {np.str_('age'): {'mean': np.float64(28.1...
    batch_effect_covariate_ranges:  {np.str_('sex'): {np.str_('0'): {np.str_(...

Selecting a number of observations#

But we can also filter out a slice of the data. For example, to select the first 10 observations, you can use the observations coordinate:

norm_data.sel(observations=slice(0, 9))

This will return a new NormData object with only the first 10 observations.

norm_data.sel(observations=slice(0, 9))
<xarray.NormData> Size: 2kB
Dimensions:            (observations: 10, response_vars: 3, covariates: 1,
                        batch_effect_dims: 2)
Coordinates:
  * observations       (observations) int64 80B 0 1 2 3 4 5 6 7 8 9
  * response_vars      (response_vars) <U22 264B 'WM-hypointensities' ... 'Br...
  * covariates         (covariates) <U3 12B 'age'
  * batch_effect_dims  (batch_effect_dims) <U4 32B 'sex' 'site'
Data variables:
    subject_ids        (observations) int64 80B 0 1 2 3 4 5 6 7 8 9
    Y                  (observations, response_vars) float64 240B 1.687e+03 ....
    X                  (observations, covariates) float64 80B 25.63 ... 19.88
    batch_effects      (observations, batch_effect_dims) <U17 1kB '1' ... 'An...
Attributes:
    real_ids:                       False
    is_scaled:                      False
    name:                           fcon1000
    unique_batch_effects:           {np.str_('sex'): [np.str_('0'), np.str_('...
    batch_effect_counts:            defaultdict(<function NormData.register_b...
    covariate_ranges:               {np.str_('age'): {'mean': np.float64(28.1...
    batch_effect_covariate_ranges:  {np.str_('sex'): {np.str_('0'): {np.str_(...

NormData with predictions#

After fitting a model and predicting on NormData, the NormData object will have new attributes holding the predictions.

Specifically, the NormData object will be extended with new data variables:

  • Z: The predicted Z scores for each response variable

  • centiles: The predicted centiles

  • logp: The predicted log-p-values for each response variable

  • Yhat: The predicted mean of the response variable

  • Y_harmonized: The harmonized response variables

  • statistics: An array of statistics for each response variable

And the following new coordinates: - centile: The specific centile values - statistic: The name of the computed statistics

from pcntoolkit import BLR, NormativeModel

# We create a very simple BLR model because it is fast to fit
model = NormativeModel(BLR())
model.fit(norm_data)  # Fitting on the data also makes predictions for that data
Process: 75157 - 2025-11-20 13:18:58 - Fitting models on 3 response variables.
Process: 75157 - 2025-11-20 13:18:58 - Fitting model for WM-hypointensities.
Process: 75157 - 2025-11-20 13:18:58 - Fitting model for Left-Lateral-Ventricle.
Process: 75157 - 2025-11-20 13:18:58 - Fitting model for Brain-Stem.
Process: 75157 - 2025-11-20 13:18:58 - Making predictions on 3 response variables.
Process: 75157 - 2025-11-20 13:18:58 - Computing z-scores for 3 response variables.
Process: 75157 - 2025-11-20 13:18:58 - Computing z-scores for WM-hypointensities.
Process: 75157 - 2025-11-20 13:18:58 - Computing z-scores for Left-Lateral-Ventricle.
Process: 75157 - 2025-11-20 13:18:58 - Computing z-scores for Brain-Stem.
Process: 75157 - 2025-11-20 13:18:58 - Computing centiles for 3 response variables.
Process: 75157 - 2025-11-20 13:18:58 - Computing centiles for WM-hypointensities.
Process: 75157 - 2025-11-20 13:18:58 - Computing centiles for Left-Lateral-Ventricle.
Process: 75157 - 2025-11-20 13:18:58 - Computing centiles for Brain-Stem.
Process: 75157 - 2025-11-20 13:18:58 - Computing log-probabilities for 3 response variables.
Process: 75157 - 2025-11-20 13:18:58 - Computing log-probabilities for WM-hypointensities.
Process: 75157 - 2025-11-20 13:18:58 - Computing log-probabilities for Left-Lateral-Ventricle.
Process: 75157 - 2025-11-20 13:18:58 - Computing log-probabilities for Brain-Stem.
Process: 75157 - 2025-11-20 13:18:58 - Computing yhat for 3 response variables.
Process: 75157 - 2025-11-20 13:18:58 - Computing yhat for WM-hypointensities.
Process: 75157 - 2025-11-20 13:18:58 - Computing yhat for Left-Lateral-Ventricle.
Process: 75157 - 2025-11-20 13:18:58 - Computing yhat for Brain-Stem.
/opt/anaconda3/envs/ptk/lib/python3.12/site-packages/pcntoolkit/dataio/norm_data.py:1094: FutureWarning: The previous implementation of stack is deprecated and will be removed in a future version of pandas. See the What's New notes for pandas 2.1.0 for details. Specify future_stack=True to adopt the new implementation and silence this warning.
  subject_ids = subject_ids.stack(level="centile")
Process: 75157 - 2025-11-20 13:18:58 - Dataset "centile" created.
    - 150 observations
    - 150 unique subjects
    - 1 covariates
    - 3 response variables
    - 2 batch effects:
            sex (1)
    site (1)

Process: 75157 - 2025-11-20 13:18:58 - Computing centiles for 3 response variables.
Process: 75157 - 2025-11-20 13:18:58 - Computing centiles for WM-hypointensities.
Process: 75157 - 2025-11-20 13:18:58 - Computing centiles for Left-Lateral-Ventricle.
Process: 75157 - 2025-11-20 13:18:58 - Computing centiles for Brain-Stem.
Process: 75157 - 2025-11-20 13:18:58 - Harmonizing data on 3 response variables.
Process: 75157 - 2025-11-20 13:18:58 - Harmonizing data for WM-hypointensities.
Process: 75157 - 2025-11-20 13:18:58 - Harmonizing data for Left-Lateral-Ventricle.
Process: 75157 - 2025-11-20 13:18:58 - Harmonizing data for Brain-Stem.
Process: 75157 - 2025-11-20 13:18:59 - Saving model to:
    /Users/stijndeboer/.pcntoolkit/saves.
norm_data.data_vars
Data variables:
    subject_ids    (observations) int64 9kB 0 1 2 3 4 ... 1072 1073 1074 1075
    Y              (observations, response_vars) float64 26kB 1.687e+03 ... 1...
    X              (observations, covariates) float64 9kB 25.63 18.34 ... 23.0
    batch_effects  (observations, batch_effect_dims) <U17 146kB '1' ... 'Sain...
    Z              (observations, response_vars) float64 26kB 0.7423 ... -0.2588
    centiles       (centile, observations, response_vars) float64 129kB 152.3...
    logp           (observations, response_vars) float64 26kB -1.158 ... -0.9537
    Yhat           (observations, response_vars) float64 26kB 1.21e+03 ... 2....
    statistics     (response_vars, statistic) float64 264B 0.1172 ... 0.9965
    Y_harmonized   (observations, response_vars) float64 26kB 1.687e+03 ... 1...
norm_data.coords
Coordinates:
  * observations       (observations) int64 9kB 0 1 2 3 ... 1072 1073 1074 1075
  * response_vars      (response_vars) <U22 264B 'WM-hypointensities' ... 'Br...
  * covariates         (covariates) <U3 12B 'age'
  * batch_effect_dims  (batch_effect_dims) <U4 32B 'sex' 'site'
  * centile            (centile) float64 40B 0.05 0.25 0.5 0.75 0.95
  * statistic          (statistic) <U8 352B 'EXPV' 'MACE' ... 'SMSE' 'ShapiroW'

Indexing of predicted data#

All the indexing methods can still be used, and they will also slice through the newly added data variables. So for example, to select the first 10 observations, you can use:

norm_data.sel(observations=slice(0, 9))

This will return a new NormData object with only the first 10 observations.

norm_data.sel(observations=slice(0, 9))
<xarray.NormData> Size: 5kB
Dimensions:            (observations: 10, response_vars: 3, covariates: 1,
                        batch_effect_dims: 2, centile: 5, statistic: 11)
Coordinates:
  * observations       (observations) int64 80B 0 1 2 3 4 5 6 7 8 9
  * response_vars      (response_vars) <U22 264B 'WM-hypointensities' ... 'Br...
  * covariates         (covariates) <U3 12B 'age'
  * batch_effect_dims  (batch_effect_dims) <U4 32B 'sex' 'site'
  * centile            (centile) float64 40B 0.05 0.25 0.5 0.75 0.95
  * statistic          (statistic) <U8 352B 'EXPV' 'MACE' ... 'SMSE' 'ShapiroW'
Data variables:
    subject_ids        (observations) int64 80B 0 1 2 3 4 5 6 7 8 9
    Y                  (observations, response_vars) float64 240B 1.687e+03 ....
    X                  (observations, covariates) float64 80B 25.63 ... 19.88
    batch_effects      (observations, batch_effect_dims) <U17 1kB '1' ... 'An...
    Z                  (observations, response_vars) float64 240B 0.7423 ... ...
    centiles           (centile, observations, response_vars) float64 1kB 152...
    logp               (observations, response_vars) float64 240B -1.158 ... ...
    Yhat               (observations, response_vars) float64 240B 1.21e+03 .....
    statistics         (response_vars, statistic) float64 264B 0.1172 ... 0.9965
    Y_harmonized       (observations, response_vars) float64 240B 1.687e+03 ....
Attributes:
    real_ids:                       False
    is_scaled:                      False
    name:                           fcon1000
    unique_batch_effects:           {np.str_('sex'): [np.str_('0'), np.str_('...
    batch_effect_counts:            defaultdict(<function NormData.register_b...
    covariate_ranges:               {np.str_('age'): {'mean': np.float64(28.1...
    batch_effect_covariate_ranges:  {np.str_('sex'): {np.str_('0'): {np.str_(...

Or, if we want to select only the WM-hypointensities, we can use:

norm_data.sel(response_vars="WM-hypointensities")

This will return a new NormData object with only the WM-hypointensities.

norm_data.sel(response_vars="WM-hypointensities").statistics
<xarray.DataArray 'statistics' (statistic: 11)> Size: 88B
array([ 1.17178773e-01,  4.50929368e-02,  3.98458621e-01, -6.56498543e+00,
        1.35593870e+00,  1.17178773e-01,  6.26199688e+02,  4.38152477e-03,
        8.85849246e-01,  8.82821227e-01,  8.47148187e-01])
Coordinates:
    response_vars  <U22 88B 'WM-hypointensities'
  * statistic      (statistic) <U8 352B 'EXPV' 'MACE' ... 'SMSE' 'ShapiroW'

Now we can use the to_dataframe method to cast that selection back to a pandas dataframe.

new_df = norm_data.sel(response_vars="WM-hypointensities").to_dataframe()
new_df.head()
X Y Y_harmonized Z batch_effects subject_ids centiles
age WM-hypointensities WM-hypointensities WM-hypointensities sex site subject_ids (WM-hypointensities, 0.05) (WM-hypointensities, 0.25) (WM-hypointensities, 0.5) (WM-hypointensities, 0.75) (WM-hypointensities, 0.95)
0 25.63 1686.7 1686.7 0.742311 1 AnnArbor_a 0 152.348837 776.050569 1209.579100 1643.107630 2266.809363
1 18.34 1371.1 1371.1 0.445220 1 AnnArbor_a 1 27.387682 651.236372 1084.867051 1518.497730 2142.346419
2 29.20 1414.8 1414.8 0.224270 1 AnnArbor_a 2 213.436546 837.129588 1270.652078 1704.174569 2327.867611
3 31.39 1830.6 1830.6 0.812878 1 AnnArbor_a 3 250.875616 874.583918 1308.117015 1741.650112 2365.358414
4 13.58 1642.4 1642.4 0.993572 1 AnnArbor_a 4 -54.364209 569.674000 1003.436412 1437.198824 2061.237034

This should give you a pretty good overview of how to work with NormData. Most of the functionality is built on top of xarray, so if you want to learn more about xarray, you can check out the xarray documentation. However, the Xarray.DataSet class does not officially support being extended, so the API does not work completely as expected.

If you have any suggestions for improvements, please let us know!

Pre-processing and split datasets#

Sometimes we have a dataset that is pre-split into train and test, and we want to use that exact data split to fit the model. We can then load the data into two NormData objects, but we have to make sure that the two datasets are compatible. This will ensure that the fitted model is applicable to both of them.

# Download an example dataset:
data = pd.read_csv(
    "https://raw.githubusercontent.com/predictive-clinical-neuroscience/PCNtoolkit-demo/refs/heads/main/data/fcon1000.csv"
)
# Create an arbitrary split as a placeholder for a predefined split ()
train, test = train_test_split(data, test_size=100)
# specify the column names to use
covariates = ["age"]
batch_effects = ["sex", "site"]
response_vars = ["WM-hypointensities", "Left-Lateral-Ventricle", "Brain-Stem"]

# create NormData objects
norm_train = NormData.from_dataframe(
    name="train", dataframe=train, covariates=covariates, batch_effects=batch_effects, response_vars=response_vars
)
norm_test = NormData.from_dataframe(
    name="test", dataframe=test, covariates=covariates, batch_effects=batch_effects, response_vars=response_vars
)
Process: 75157 - 2025-11-20 13:19:02 - Dataset "train" created.
    - 978 observations
    - 978 unique subjects
    - 1 covariates
    - 3 response variables
    - 2 batch effects:
            sex (2)
    site (23)

Process: 75157 - 2025-11-20 13:19:02 - Dataset "test" created.
    - 100 observations
    - 100 unique subjects
    - 1 covariates
    - 3 response variables
    - 2 batch effects:
            sex (2)
    site (18)
# Should print false, because the train and test split do not contain the same sites
print(norm_train.check_compatibility(norm_test))
True
norm_train.check_compatibility(norm_test)
True
norm_train.make_compatible(norm_test)
norm_train.check_compatibility(norm_test)
True