Clustering analysis of growth curves
This section provides examples of the clustering module used in the unified preprocessing pipeline. The purpose of this module is to group growth curves according to their shape, rather than their absolute OD magnitude. This is useful for distinguishing different classes of temporal behaviour among the growing curves.
The clustering procedure is based on k-means applied to row-wise z-scored curves, with optional extensions for:
- pre-screening constant curves before k-means,
- post-hoc reassignment of flat curves based on a linear trend test,
- forced shift-detection with post-hoc reassignment of curves that are closer to exponential prototypes than to their assigned k-means centroid.
- Clustering analysis of growth curves
- Clustering of growth curves
- Basic clustering on z-scored curves
- Behaviour of row-wise z-score normalisation
- Constant pre-screening of flat curves
- Trend-test reassignment of flat curves
- Priority between pre-screening and trend-test modes
- Exponential prototype relabeling
- Centroid computation
- Repeated k-means initialisations
- Edge case: empty input
- Full clustering workflow example
- Summary
Clustering of growth curves
In this section, we present different examples of how to use the clustering part of the preprocessing pipeline. To run these examples, you will typically need the following Julia packages:
using Kinbiont
using Random
using Statistics
using PlotsThe clustering result is accessed through the preprocess function, which returns a GrowthData object with clusters, centroids, and wcss fields populated:
proc = preprocess(data, opts)
# proc.clusters → Vector{Int}: cluster assignment for each curve
# proc.centroids → Matrix{Float64}: cluster centroids in z-normalised space
# proc.wcss → Float64: method-independent centroid WCSSWCSS is recomputed from the final labels for every supported clustering method. Only non-empty clusters populated by the algorithm contribute; non-growing sentinels and DBSCAN noise are excluded.
Basic clustering on z-scored curves
In this first example, we simulate a small set of synthetic growth curves with different temporal shapes and cluster them using plain k-means on z-scored trajectories.
The purpose of this example is to show the simplest use of the clustering module, without constant pre-screening, trend testing, or exponential prototype relabeling.
First, we define the time axis:
times = collect(0.0:2.0:48.0)
n_tp = length(times)Next, we define some helper functions to generate synthetic curves with different behaviours:
function logistic_like_curve(t, lag, rate, amp, baseline)
return baseline .+ amp ./ (1 .+ exp.(-rate .* (t .- lag)))
end
function flat_curve(t, level)
return fill(level, length(t))
end
function delayed_growth_curve(t, lag, rate, amp, baseline)
y = baseline .+ amp ./ (1 .+ exp.(-rate .* (t .- lag)))
y[t .< lag] .= baseline
return y
endWe now generate a synthetic dataset composed of flat curves, standard logistic-like curves, and delayed-growth curves:
Random.seed!(1234)
curves = Matrix{Float64}(undef, 12, n_tp)
for i in 1:4
curves[i, :] = flat_curve(times, 0.08) .+ randn(n_tp) .* 0.002
end
for i in 5:8
curves[i, :] = logistic_like_curve(times, 20.0, 0.35, 0.8, 0.05) .+ randn(n_tp) .* 0.01
end
for i in 9:12
curves[i, :] = delayed_growth_curve(times, 28.0, 0.45, 0.9, 0.05) .+ randn(n_tp) .* 0.01
endWe can visualise the simulated curves:
p = plot(xlabel="Time", ylabel="OD", size=(500, 350), label=nothing)
for i in 1:size(curves, 1)
plot!(p, times, curves[i, :], label=nothing)
end
display(p)We now define the clustering options and run the clustering:
opts = FitOptions(
cluster=true,
n_clusters=3,
cluster_prescreen_constant=false,
cluster_trend_test=false,
cluster_exp_prototype=false,
kmeans_n_init=10,
kmeans_seed=1234,
kmeans_max_iters=1000,
kmeans_tol=1e-6
)
data_ex = GrowthData(curves, times, [string(i) for i in 1:size(curves, 1)])
proc = preprocess(data_ex, opts)
labels = proc.clusters
centroids = proc.centroids
wcss = proc.wcssThe clustering result can be inspected through:
labels
centroids
wcssWe can also plot the curves coloured by their assigned cluster:
for k in 1:maximum(labels)
idx = findall(labels .== k)
p = plot(title="Cluster $k", xlabel="Time", ylabel="OD", size=(400, 300))
for i in idx
plot!(p, times, curves[i, :], label=nothing)
end
display(p)
endIn this example, the flat curves and the two kinds of growing curves are expected to be separated mainly according to their shape, because the clustering is performed after row-wise z-score normalisation.
Behaviour of row-wise z-score normalisation
Each curve is z-scored independently across its own time points. If a curve is effectively constant (standard deviation < 1e-12), the whole row is replaced by zeros:
if s < 1e-12
out[i, :] .= 0.0
else
out[i, :] = zscore(row)
endThis means that perfectly flat curves become all-zero trajectories in z-normalised space, which is consistent with the intended shape-based comparison.
Constant pre-screening of flat curves
In many datasets, some wells are clearly non-growing or nearly constant. In this case, it can be useful to identify these curves before running k-means, so that the dynamic clusters are learned only from the genuinely varying trajectories.
This behaviour is activated with:
cluster_prescreen_constant=trueFor each curve, the code computes:
- a low quantile
q_low, - a high quantile
q_high.
If the low quantile is positive, a curve is classified as constant when:
q_high <= q_low + cluster_tol_const * abs(q_low)If the low quantile is non-positive, the code switches to a fallback rule based on whether the overall quantile range is negligible.
Constant pre-screening: synthetic example
In this example, we simulate a dataset with many flat curves and a smaller set of dynamic curves.
Random.seed!(2222)
curves = Matrix{Float64}(undef, 15, n_tp)
for i in 1:7
curves[i, :] = flat_curve(times, 0.06) .+ randn(n_tp) .* 0.001
end
for i in 8:11
curves[i, :] = logistic_like_curve(times, 18.0, 0.30, 0.75, 0.04) .+ randn(n_tp) .* 0.01
end
for i in 12:15
curves[i, :] = delayed_growth_curve(times, 30.0, 0.50, 0.95, 0.04) .+ randn(n_tp) .* 0.01
endWe define clustering options with constant pre-screening enabled:
opts = FitOptions(
cluster=true,
n_clusters=3,
cluster_prescreen_constant=true,
cluster_q_low=0.10,
cluster_q_high=0.90,
cluster_tol_const=0.9,
cluster_trend_test=false,
cluster_exp_prototype=false,
kmeans_n_init=10,
kmeans_seed=42,
kmeans_max_iters=1000,
kmeans_tol=1e-6
)
data_ex = GrowthData(curves, times, [string(i) for i in 1:size(curves, 1)])
proc = preprocess(data_ex, opts)
labels = proc.clusters
centroids = proc.centroids
wcss = proc.wcssIn this mode:
- constant curves are assigned directly to cluster
n_clusters, - k-means is run only on the dynamic subset,
- the dynamic curves receive labels
1:(n_clusters-1).
The corresponding labels can be inspected through:
labelsTo visualise the result:
for k in 1:maximum(labels)
idx = findall(labels .== k)
p = plot(title="Cluster $k", xlabel="Time", ylabel="OD", size=(400, 300))
for i in idx
plot!(p, times, curves[i, :], label=nothing)
end
display(p)
endA special edge case is also handled explicitly in the code: if all curves are classified as constant, no k-means step is run and wcss remains 0.0.
Trend-test reassignment of flat curves
A different strategy for handling flat curves is to first run k-means and then reassign curves whose overall slope is not statistically significant.
This behaviour is activated with:
cluster_trend_test=trueIn this case, the code:
- runs k-means on all z-scored curves using
max(1, n_clusters - 1)clusters, - then applies a post-hoc trend test on the original curves,
- reassigns curves with non-significant slope to a dedicated flat cluster with label
n_clusters.
The OLS slope is computed analytically and evaluated with a two-tailed t-test using a Student t distribution with n - 2 degrees of freedom.
The reassignment rule is:
- if
p >= 0.05, the curve is reassigned to the flat cluster, - otherwise, it keeps its k-means label.
Trend-test reassignment: synthetic example
In this example, we simulate curves that are not perfectly constant, but still have very weak overall trend.
Random.seed!(3333)
curves = Matrix{Float64}(undef, 15, n_tp)
for i in 1:5
curves[i, :] = 0.08 .+ 0.002 .* sin.(times ./ 3) .+ randn(n_tp) .* 0.002
end
for i in 6:10
curves[i, :] = logistic_like_curve(times, 16.0, 0.35, 0.7, 0.05) .+ randn(n_tp) .* 0.01
end
for i in 11:15
curves[i, :] = delayed_growth_curve(times, 30.0, 0.55, 0.9, 0.05) .+ randn(n_tp) .* 0.01
endWe now activate the trend-test mode:
opts = FitOptions(
cluster=true,
n_clusters=3,
cluster_prescreen_constant=false,
cluster_trend_test=true,
cluster_exp_prototype=false,
kmeans_n_init=10,
kmeans_seed=7,
kmeans_max_iters=1000,
kmeans_tol=1e-6
)
data_ex = GrowthData(curves, times, [string(i) for i in 1:size(curves, 1)])
proc = preprocess(data_ex, opts)
labels = proc.clustersThe cluster labels can be inspected through:
labelsAnd visualised with:
for k in 1:maximum(labels)
idx = findall(labels .== k)
p = plot(title="Cluster $k", xlabel="Time", ylabel="OD", size=(400, 300))
for i in idx
plot!(p, times, curves[i, :], label=nothing)
end
display(p)
endThis strategy is useful when flat curves are noisy enough to escape the quantile-ratio filter, but still do not show a statistically significant linear trend.
Priority between pre-screening and trend-test modes
The code does not combine constant pre-screening and trend-test reassignment as two sequential steps.
The internal priority is:
- if
cluster_prescreen_constant=true, use pre-screening mode; - else if
cluster_trend_test=true, use trend-test mode; - otherwise, use plain k-means.
After this choice, exponential prototype relabeling may still be applied as an additional final step.
So, if both cluster_prescreen_constant=true and cluster_trend_test=true, the trend-test branch is ignored.
Exponential prototype relabeling
The clustering module also supports a post-processing step that identifies curves closer to a family of idealised exponential shapes than to their assigned k-means centroid.
This behaviour is activated with:
cluster_exp_prototype=trueIn this case, the code:
- builds a set of z-scored exponential prototypes,
- computes the squared distance between each curve and the nearest exponential prototype,
- computes the squared distance to the curve's currently assigned centroid,
- reassigns the curve if the exponential prototype is closer.
How the exponential label is chosen
The exponential cluster label is not arbitrary.
The code first computes a preferred label:
n_clusters - 1whencluster_prescreen_constant=true,n_clustersotherwise.
If that preferred label is already present in the current labels, the code allocates a new label equal to maximum(labels) + 1.
So when exponential prototype relabeling is enabled, the final labels may extend beyond 1:opts.n_clusters.
Exponential prototype relabeling: synthetic example
We simulate a dataset containing flat curves, logistic-like curves, and strongly exponential trajectories.
function exponential_like_curve(t, rate, amp, baseline)
tnorm = (t .- minimum(t)) ./ (maximum(t) - minimum(t))
return baseline .+ amp .* ((exp.(rate .* tnorm) .- 1.0) ./ (exp(rate) - 1.0))
endRandom.seed!(4444)
curves = Matrix{Float64}(undef, 15, n_tp)
for i in 1:5
curves[i, :] = flat_curve(times, 0.05) .+ randn(n_tp) .* 0.002
end
for i in 6:10
curves[i, :] = logistic_like_curve(times, 20.0, 0.30, 0.8, 0.04) .+ randn(n_tp) .* 0.01
end
for i in 11:15
curves[i, :] = exponential_like_curve(times, 5.0, 0.9, 0.04) .+ randn(n_tp) .* 0.01
endWe now activate exponential prototype relabeling:
opts = FitOptions(
cluster=true,
n_clusters=3,
cluster_prescreen_constant=false,
cluster_trend_test=false,
cluster_exp_prototype=true,
kmeans_n_init=10,
kmeans_seed=100,
kmeans_max_iters=1000,
kmeans_tol=1e-6
)
data_ex = GrowthData(curves, times, [string(i) for i in 1:size(curves, 1)])
proc = preprocess(data_ex, opts)
labels = proc.clusters
centroids = proc.centroids
wcss = proc.wcssTo visualise the result:
for k in 1:maximum(labels)
idx = findall(labels .== k)
p = plot(title="Cluster $k", xlabel="Time", ylabel="OD", size=(400, 300))
for i in idx
plot!(p, times, curves[i, :], label=nothing)
end
display(p)
endCentroid computation
After the final cluster labels are determined, the centroids are computed by averaging the z-scored curves assigned to each cluster.
The returned centroid matrix is expressed in z-normalised space, not in the original OD scale. This means that the centroids represent shape prototypes, not average absolute OD trajectories.
If a cluster is empty, the corresponding centroid row remains a vector of zeros.
Centroid computation: simple example
data_ex = GrowthData(curves, times, [string(i) for i in 1:size(curves, 1)])
proc = preprocess(data_ex, opts)
labels = proc.clusters
centroids = proc.centroidsThe centroids can be plotted directly:
p = plot(title="Cluster centroids (z-normalised space)", xlabel="Time index", ylabel="Z-score", size=(500, 350))
for k in 1:size(centroids, 1)
plot!(p, 1:size(centroids, 2), centroids[k, :], label="Cluster $k")
end
display(p)If needed, original-scale prototypes can be reconstructed manually by averaging the original curves within each cluster:
for k in 1:maximum(labels)
idx = findall(labels .== k)
if !isempty(idx)
original_centroid = vec(mean(curves[idx, :], dims=1))
println("Original-scale centroid for cluster $k:")
println(original_centroid)
end
endRepeated k-means initialisations
The k-means step runs kmeans_n_init times and returns the result with the lowest total cost.
The implementation uses a deterministic MersenneTwister, with:
- seed
42whenopts.kmeans_seed == 0, - the user-provided seed otherwise.
The same RNG object is then reused across repeated initialisations, so the sequence of starts is deterministic for a given seed.
Example of repeated initialisation
opts = FitOptions(
cluster=true,
n_clusters=4,
kmeans_n_init=20,
kmeans_seed=123,
kmeans_max_iters=5000,
kmeans_tol=1e-8
)
data_ex = GrowthData(curves, times, [string(i) for i in 1:size(curves, 1)])
proc = preprocess(data_ex, opts)
labels = proc.clusters
wcss = proc.wcssThis is useful when the user wants a more stable partition and wants to reduce the chance of keeping a poor local optimum.
Edge case: empty input
The clustering code explicitly handles the case where the input contains zero curves.
If the curve matrix has zero rows, preprocess returns a GrowthData with:
- an empty cluster vector,
- a zero centroid matrix of size
n_clusters × n_timepoints, wcss = 0.0.
This behaviour ensures that downstream code can still rely on a consistent return type.
Full clustering workflow example
In this final example, we simulate a mixed dataset and compare the behaviour of different clustering modes.
We first generate the dataset:
Random.seed!(5555)
curves = Matrix{Float64}(undef, 18, n_tp)
for i in 1:6
curves[i, :] = flat_curve(times, 0.06) .+ randn(n_tp) .* 0.002
end
for i in 7:12
curves[i, :] = logistic_like_curve(times, 18.0, 0.35, 0.8, 0.04) .+ randn(n_tp) .* 0.01
end
for i in 13:18
curves[i, :] = exponential_like_curve(times, 4.5, 0.9, 0.04) .+ randn(n_tp) .* 0.01
end
data_ex = GrowthData(curves, times, [string(i) for i in 1:size(curves, 1)])We first run plain clustering:
opts_plain = FitOptions(
cluster=true,
n_clusters=3,
cluster_prescreen_constant=false,
cluster_trend_test=false,
cluster_exp_prototype=false,
kmeans_n_init=10,
kmeans_seed=11
)
proc_plain = preprocess(data_ex, opts_plain)
labels_plain = proc_plain.clusters
wcss_plain = proc_plain.wcssThen clustering with constant pre-screening:
opts_const = FitOptions(
cluster=true,
n_clusters=3,
cluster_prescreen_constant=true,
cluster_q_low=0.10,
cluster_q_high=0.90,
cluster_tol_const=0.9,
cluster_trend_test=false,
cluster_exp_prototype=false,
kmeans_n_init=10,
kmeans_seed=11
)
proc_const = preprocess(data_ex, opts_const)
labels_const = proc_const.clusters
wcss_const = proc_const.wcssFinally clustering with exponential prototype relabeling:
opts_exp = FitOptions(
cluster=true,
n_clusters=3,
cluster_prescreen_constant=false,
cluster_trend_test=false,
cluster_exp_prototype=true,
kmeans_n_init=10,
kmeans_seed=11
)
proc_exp = preprocess(data_ex, opts_exp)
labels_exp = proc_exp.clusters
wcss_exp = proc_exp.wcssThe results can be compared through:
labels_plain
wcss_plain
labels_const
wcss_const
labels_exp
wcss_expThis workflow illustrates how the choice of clustering mode affects the final partition of the growth curves.
Summary
The clustering module groups curves according to shape, not raw OD scale, by applying k-means to row-wise z-scored trajectories.
Depending on the chosen options, the user can:
- run plain k-means on all curves,
- reserve one cluster for clearly constant curves using quantile-ratio pre-screening,
- reserve one cluster for flat curves using a slope significance test,
- add an exponential-like cluster by comparing curves against analytical prototypes.
These options make the clustering stage flexible and suitable for both exploratory analyses and more structured downstream workflows in growth-curve analysis.