K-Means — operating regimes, asset cohorts & network communities
Effort: ~1–1.5 hours · You'll build: behavioural and graph feature vectors and
cluster them three ways · Stack: the SDK for data and traversal, plus numpy,
pandas, scikit-learn, and networkx for the graph part.
K-Means finds structure with no labels: it groups points so each sits near its cluster's centre. That one idea answers three different operational questions, all covered here — what modes does this asset run in? (operating regimes), which assets behave alike? (cohorts), and which parts of the network belong together? (communities). The trick is always the same: turn the thing you want to group into a feature vector.
These steps read series (and a graph for section 3) that already exist. Generate a sandbox first.
No background needed. Skim the gentle primer for the ideas in plain language — model, feature, training, and the algorithm itself — and use Generate sample data for a sandbox to run this against.
1. Asset cohorts — group assets that behave alike
Summarise each asset by a behavioural signature — a few aggregates of its key series — then cluster. Assets land in peer groups you can benchmark within.
- Python
- Java
- Rust
import datahub_sdk, numpy as np, pandas as pd
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
client = datahub_sdk.DataHubClient.from_env()
def signature(asset):
def agg(metric, *aggs):
rf = datahub_sdk.RetrieveFilter(
ts=f"{asset}_{metric}",
start=pd.Timestamp.now(tz="UTC") - pd.Timedelta(days=30),
end=pd.Timestamp.now(tz="UTC"),
aggregates=list(aggs), granularity="30d")
return client.timeseries.retrieve_datapoints(rf)[0].get_datapoints()[-1]
load = agg("load_mw", "avg", "max")
return {"avg_load": float(load.average), "peak_load": float(load.max)}
assets = ["pump_07", "pump_08", "pump_11", "pump_19"] # ...your fleet
X = pd.DataFrame([signature(a) for a in assets], index=assets)
Xs = StandardScaler().fit_transform(X)
km = KMeans(n_clusters=4, n_init=10, random_state=0).fit(Xs)
X["cohort"] = km.labels_
// Java pulls each asset's aggregates the same way; the clustering below is Python.
var filter = new RetrieveFilter();
filter.setExternalId("pump_07_load_mw");
filter.setStart(ZonedDateTime.now().minusDays(30));
filter.setEnd(ZonedDateTime.now());
filter.setAggregates(List.of("avg", "max"));
filter.setGranularity("30d");
var request = new DataRetriever<RetrieveFilter>();
request.setItems(List.of(filter));
var sig = client.timeseries().retrieve(request).getItems().get(0).getDatapoints();
// Rust pulls each asset's aggregates the same way; the clustering below is Python.
use dataplatform_rust_sdk::generic::{DataWrapper, RetrieveFilter};
use chrono::Utc;
let filter = RetrieveFilter {
external_id: Some("pump_07_load_mw".into()),
start: Some(Utc::now() - chrono::Duration::days(30)),
end: Some(Utc::now()),
aggregates: Some(vec!["avg".into(), "max".into()]),
granularity: Some("30d".into()),
..Default::default()
};
let sig = api.time_series
.retrieve_datapoints(&DataWrapper::from(vec![filter])).await?
.get_items().remove(0);
The payoff: the asset that doesn't fit its cohort
An asset far from its own cluster's centre is behaving unlike its peers — a strong, label-free anomaly signal. Flag it.
dist = np.linalg.norm(Xs - km.cluster_centers_[km.labels_], axis=1)
for asset, d in zip(assets, dist):
if d > np.percentile(dist, 90):
client.events.create([datahub_sdk.Event(
external_id=f"peer_outlier_{asset}_{int(pd.Timestamp.now().timestamp())}",
type="peer_outlier", status="open",
event_time=pd.Timestamp.now(tz="UTC"),
metadata={"asset": asset, "cohort": str(int(km.labels_[assets.index(asset)]))})])
2. Operating regimes — group an asset's states
Now cluster time instead of assets. Each row is one moment described by the asset's sensor vector; the clusters are its operating modes — idle, ramp, steady, overload. Labelling history this way makes "we only see this fault in mode 3" answerable.
def load_series(external_id): # one-minute averages over the recent window
rf = datahub_sdk.RetrieveFilter(
ts=external_id,
start=pd.Timestamp.now(tz="UTC") - pd.Timedelta(days=14),
end=pd.Timestamp.now(tz="UTC"),
aggregates=["avg"], granularity="1m", limit=100_000)
pts = client.timeseries.retrieve_datapoints(rf)[0].get_datapoints()
return pd.Series([float(p.average) for p in pts],
index=pd.to_datetime([p.timestamp for p in pts], utc=True)).sort_index()
# one row per minute: [load, temp, pressure, flow] for a single asset
states = pd.DataFrame({c: load_series(f"unit_3_{c}") for c in
["load_mw", "temp_c", "pressure_kpa", "flow_m3h"]}).dropna()
regimes = KMeans(n_clusters=4, n_init=10, random_state=0).fit_predict(
StandardScaler().fit_transform(states))
states["regime"] = regimes # now every minute is tagged with its operating mode
3. Network communities — cluster the graph
K-Means needs vectors, and a raw graph isn't one — so featurise it. Either enrich the behavioural vector with graph-structural features (so two assets are "alike" only if they behave and sit similarly in the network), or cluster the structure directly with spectral clustering, which is literally K-Means on the graph Laplacian's eigenvectors.
import networkx as nx
# (a) structural features per node, to add to the behavioural signature
def graph_features(asset):
net = client.resources.fetch_related(external_id=asset, depth=2)
g = nx.Graph(); g.add_edges_from((e.start, e.end) for e in net.edges)
fid = next((n.id for n in net.nodes if n.external_id == asset), None)
pr = nx.pagerank(g) if g.number_of_edges() else {}
return {
"degree": g.degree(fid) if fid in g else 0,
"pagerank": pr.get(fid, 0.0),
"clustering": nx.clustering(g, fid) if fid in g else 0.0,
}
# (b) or detect communities directly — spectral clustering = K-Means on the Laplacian
from sklearn.cluster import SpectralClustering
big_network = client.resources.fetch_related(external_id="account_1", depth=6)
g = nx.Graph(); g.add_edges_from((e.start, e.end) for e in big_network.edges)
A = nx.to_numpy_array(g)
communities = SpectralClustering(n_clusters=2, affinity="precomputed", # k ≤ number of nodes
assign_labels="kmeans").fit_predict(A)
So yes — K-Means works with graphs: feed it structural features, or let spectral clustering turn the network into vectors for it.
Choosing k
Don't guess the number of clusters — let the data say. The silhouette score peaks at the k that separates clusters best:
from sklearn.metrics import silhouette_score
scores = {k: silhouette_score(Xs, KMeans(k, n_init=10, random_state=0).fit_predict(Xs))
for k in range(2, 8)}
best_k = max(scores, key=scores.get)
Further reading
- k-means — Wikipedia
- Spectral clustering (clustering a graph) — Wikipedia
- Silhouette score (choosing k) — Wikipedia
See also
- Correlate alarms with the graph — the traversal the graph features build on.
- Asset health scoring — score within a cohort once you have one.
- Fraud classification — graph features feeding a supervised model.