4. Simulating concept drifts with the DriftStream API#
This tutorial demonstrates how to use the DriftStream API in CapyMOA:
We start by showing how one can use a synthetic generator without concept drift (RandomTreeGenerator).
We delve into the two ways of defining a DriftStream:
DriftStream Position: ``drift position`` + ``drift width``.
DriftStream Range: ``concept num_instances`` + ``drift num_instances``.
Other examples can be found in Tutorial 6: Exploring Advanced Features, such as configuring and manipulating MOA streams directly.
Also, Tutorial: Drift Detection complements this tutorial by demonstrating the drift detection API and the various algorithms implemented in capymoa.
References:
Cerqueira, V., Gomes, H. M., Heyden, M., Pfahringer, B., & Bifet, A. (2026). A Framework for Evaluating and Benchmarking Concept Drift Detection Methods. ACM SIGKDD Conference on Knowledge Discovery and Data Mining.
Gama, J., Žliobaitė, I., Bifet, A., Pechenizkiy, M., & Bouchachia, A. (2014). A survey on concept drift adaptation. ACM Computing Surveys, 46(4), 1-37.
More information about CapyMOA can be found at https://www.capymoa.org.
last update on 06/08/2026
4.1 CapyMOA synthetic generators#
In this example, we show how to use
RandomTreeGeneratorto generate a synthetic stream. No concept drift, just a synthetic stream.
[1]:
from capymoa.stream.generator import RandomTreeGenerator
from capymoa.evaluation import prequential_evaluation
from capymoa.evaluation.visualization import plot_windowed_results
from capymoa.classifier import HoeffdingTree
rtg_stream = RandomTreeGenerator()
ht = HoeffdingTree(schema=rtg_stream.get_schema())
results_ht = prequential_evaluation(
max_instances=10000, window_size=1000, stream=rtg_stream, learner=ht
)
plot_windowed_results(results_ht, metric="accuracy")
4.2 CapyMOA DriftStream builder API#
In CapyMOA, the concepts and drifts are clearly outlined on a list format. There are two ways of specifying a DriftStream in this list format:
DriftStream Position: ``drift position`` + ``drift width``: the start and end of a concept is determined by the presence of an
AbruptDriftorGradualDriftobject.DriftStream([
SEA(1),
AbruptDrift(position=1000),
SEA(2),
GradualDrift(position=2000, width=500),
SEA(3)])
The
GradualDriftcan also be specified in terms ofstartandend.DriftStream([
SEA(1),
AbruptDrift(position=1000),
SEA(2),
GradualDrift(start=1750, end=2250),
SEA(3)])
DriftStream Range: ``concept num_instances`` + ``drift num_instances``: the start and end of a concept is determined by the amount of instances generated for it, the same thing can be said about
GradualDriftswhich do not have astartorendbut the number of instances i.e. thewidthof that drifting region (or drifting window). Notice that we can’t specify a driftpositionor driftstartandendwhen using the Range version because that would be confusing and error prone. The specification of the DriftStream, in this version, doesn’t explicitly tells us about the locations of the drifts on the stream, so it is less error prone if we don’t allow the user to use this approach mixed with thedrift positionone. Example:DriftStream([
Concept(SEA(1), num_instances=1000),
AbruptDrift(),
Concept(SEA(2), num_instances=500),
GradualDrift(num_instances=500),
Concept(SEA(3), num_instances=500)])
Why do we need the ``Concept()`` specification in the ``num_instances``? The Stream class, i.e. base class for SEA and other synthetic generators do not implement the concept of
max_instancesornum_instances. If we were to implement that, we would lose the idea of synthetic streams being unbounded. It is a design choice, whenever we want to control a stream length, it is done externally to its definition.The ``DriftStream`` specification in the
positionversion does not specify the totalsizeof the stream, i.e. notice how theSEA(3)at the end is unbounded, there is no drift object signaling its end. That is intentional as the user specifying theDriftStreamand manipulating it defines the end of the stream externally. This is true for synthetic streams and also for limiting snapshot streams like electricity and others that are read from files.
4.2.1 DriftStream position + width#
Specifying drift location using the first version.
We can use either position + width or start + end to define GradualDrifts in this approach.
GradualDrift(position=10000, width=2000)
or
GradualDrift(start=9000, end=12000)
Important: meta-data about the specified Drifts is accessible from the stream object.
print(f'The definition of the DriftStream is accessible through the object:\n {stream_sea2drift}')
Furthermore, this meta-data is interpreted by the
plot_windowed_resultsfunction producing plots that automatically indicates drift locations.
[2]:
from capymoa.stream.drift import DriftStream, AbruptDrift, GradualDrift
from capymoa.classifier import OnlineBagging
from capymoa.stream.generator import SEA
stream_sea2drift = DriftStream(
stream=[
SEA(function=1),
AbruptDrift(position=5000),
SEA(function=3),
GradualDrift(position=10000, width=2000),
# GradualDrift(start=9000, end=12000),
SEA(function=1),
]
)
OB = OnlineBagging(schema=stream_sea2drift.get_schema(), ensemble_size=10)
results_sea2drift_OB = prequential_evaluation(
stream=stream_sea2drift, learner=OB, window_size=100, max_instances=15000
)
print(
f"The definition of the DriftStream is accessible through the object:\n {stream_sea2drift}"
)
plot_windowed_results(results_sea2drift_OB, metric="accuracy")
The definition of the DriftStream is accessible through the object:
SEA(function=1),AbruptDrift(position=5000),SEA(function=3),GradualDrift(position=10000, start=9000, end=11000, width=2000),SEA(function=1)
4.2.2 DriftStream range#
This version of the DriftStream builder specifies how long each concept and drift lasts instead of where each drift lands, i.e.
concept num_instancesanddrift num_instances.Each component contributes its own stretch of the stream, so the definition reads as a timeline: a concept runs for its length, an
AbruptDrift()switches at the point it is reached, and aGradualDrift(num_instances=...)spans its own stretch centred on that point.Concepts are wrapped in
Concept(stream, num_instances=...). The wrapper is needed because aStreamhas no length of its own – MOA generators are unbounded.The two forms cannot be mixed. A range definition does not say where its drifts land, so a stray
positionwould describe a location the rest of the definition contradicts.
[3]:
from capymoa.stream.drift import DriftStream, Concept, AbruptDrift, GradualDrift
from capymoa.stream.generator import SEA
stream_range = DriftStream(
stream=[
Concept(SEA(function=1), num_instances=1000),
AbruptDrift(),
Concept(SEA(function=2), num_instances=500),
GradualDrift(num_instances=500),
Concept(SEA(function=3), num_instances=500),
]
)
# The lengths are translated into the positions they imply, so the drift
# metadata is the same as if they had been written out by hand.
for drift in stream_range.get_drifts():
print(drift)
AbruptDrift(position=1000)
GradualDrift(position=1750, start=1500, end=2000, width=500)
The lengths are translated into the positions they imply, so a
rangedefinition is simply another way of writing apositionone.Evaluating both and plotting them side by side makes that concrete: the drift markers land in the same places, because they are the same drifts.
[4]:
from capymoa.classifier import OnlineBagging
# The same stream written both ways.
stream_by_range = DriftStream(
stream=[
Concept(SEA(function=1), num_instances=5000),
AbruptDrift(),
Concept(SEA(function=3), num_instances=5000),
GradualDrift(num_instances=2000),
Concept(SEA(function=1), num_instances=3000),
]
)
stream_by_position = DriftStream(
stream=[
SEA(function=1),
AbruptDrift(position=5000),
SEA(function=3),
GradualDrift(position=11000, width=2000),
SEA(function=1),
]
)
print("range :", [str(d) for d in stream_by_range.get_drifts()])
print("position:", [str(d) for d in stream_by_position.get_drifts()])
print(
"identical drifts:",
[str(d) for d in stream_by_range.get_drifts()]
== [str(d) for d in stream_by_position.get_drifts()],
)
for name, stream in (("range", stream_by_range), ("position", stream_by_position)):
learner = OnlineBagging(schema=stream.get_schema(), ensemble_size=10)
results = prequential_evaluation(
stream=stream, learner=learner, window_size=100, max_instances=15000
)
print(f"\n{name} form:")
plot_windowed_results(results, metric="accuracy")
range : ['AbruptDrift(position=5000)', 'GradualDrift(position=11000, start=10000, end=12000, width=2000)']
position: ['AbruptDrift(position=5000)', 'GradualDrift(position=11000, start=10000, end=12000, width=2000)']
identical drifts: True
range form:
position form:
4.2.3 What a length does and does not guarantee#
num_instancesplaces the drifts along the stream. It does not ration instances between concepts.Around an
AbruptDriftit is exact: the switch is a step, so the first concept contributes precisely its length.Around a
GradualDriftthe two concepts overlap. Both are drawn from while the transition runs, so each of them contributes its own length plus a share of the window.The transition is confined to the window: before it the old concept is used exclusively, after it the new one. A concept is therefore drawn from over
num_instances + (width / 2)instances on average.
The drift window is exactly where the definition says. The provenance of an individual instance inside it is probabilistic, which matters if you are building a labelled benchmark.
[5]:
# Ask the stream itself rather than assuming a shape: this is the probability
# that an instance at a given position comes from the *new* concept.
drift = stream_range.get_drifts()[1] # the GradualDrift resolved above
transition = stream_range._root # the internal node holding that drift
print(f"window: start={drift.start} centre={drift.position} end={drift.end}\n")
print(f"{'instance':>10} {'P(new concept)':>15} where")
for n, where in [
(drift.start - 1, "before the window"),
(drift.start, "window opens"),
(drift.position, "centre"),
(drift.end, "window closes"),
(drift.end + 1, "after the window"),
]:
print(f"{n:>10} {transition.probability_of_new_concept(n):>15.4f} {where}")
print("\nOutside the window the probability is exactly 0 or exactly 1,")
print("so the older concept does not reappear once the drift has finished.")
window: start=1500 centre=1750 end=2000
instance P(new concept) where
1499 0.0000 before the window
1500 0.0000 window opens
1750 0.5000 centre
2000 1.0000 window closes
2001 1.0000 after the window
Outside the window the probability is exactly 0 or exactly 1,
so the older concept does not reappear once the drift has finished.
4.2.4 Telling exactly how many instances came from each concept#
Because the concepts overlap inside a gradual window, the definition alone does not tell you how much of each you actually got.
describe(horizon)prints it as a table andget_concept_counts(horizon)returns the numbers. Thehorizondefaults to the length the definition implies.The ``in drift`` column is the part of a concept’s draws that happened inside a gradual window. That is why a concept declared as 500 is drawn from around 740 times: its own 500, plus its share of the 500-instance transition beside it.
Neither needs the stream to be run first. A transition decides using only its own seeded generator and counter, so the routing is replayed without generating any data.
⚠️ With a real, finite stream this matters. A concept must hold enough instances for its declared length and for the share of any adjacent gradual window it will be drawn from. Backing a num_instances=500 concept with exactly 500 real instances is not enough if a gradual drift sits next to it. Use these counts to size the data before running the experiment.
[6]:
# describe() prints the table; get_concept_counts() returns the numbers.
print(stream_range.describe())
print("\nraw counts, in the order the concepts were defined:")
print(" ", stream_range.get_concept_counts())
# Neither needs the stream to be run, and the horizon can be anything.
print("\nover the first 1000 instances only:")
print(" ", stream_range.get_concept_counts(1000))
DriftStream over 2500 instances, range form
horizon: from the declared lengths
concept declared drawn in drift share
SEA(function=1) 1000 999 0 40.0%
SEA(function=2) 500 738 237 29.5%
SEA(function=3) 500 763 263 30.5%
drifts
AbruptDrift(position=1000)
GradualDrift(position=1750, start=1500, end=2000, width=500)
raw counts, in the order the concepts were defined:
[999, 738, 763]
over the first 1000 instances only:
[999, 1, 0]
4.2.5 Choosing how a gradual drift transitions#
A
GradualDriftmixes the two concepts across its window. How it mixes is thetransition_function.The transition is confined to the window: before it, instances come from the old concept only; after it, from the new one only.
Two are built in:
"sigmoid"(default) – a smooth S-curve, parametrised from the width so it completes inside the window."linear"– the probability rises at a constant rate across the window.
Both are deterministic and both finish where the window finishes. They differ in shape, not in extent.
[7]:
# The same drift under each ramp. The window runs from 1000 to 1200.
for transition in ("sigmoid", "linear"):
stream = DriftStream(
stream=[
Concept(SEA(function=1), num_instances=1000),
GradualDrift(num_instances=200, transition_function=transition),
Concept(SEA(function=3), num_instances=1000),
]
)
node = stream._root # the internal transition node, for illustration
drift = stream.get_drifts()[0]
points = [
drift.start - 1,
drift.start,
1050,
drift.position,
1150,
drift.end,
drift.end + 1,
]
values = [f"{node.probability_of_new_concept(n):.3f}" for n in points]
print(f"{transition:>8} " + " ".join(f"{n}:{v}" for n, v in zip(points, values)))
print("\nBoth are exactly 0 before the window and exactly 1 after it.")
print("Inside, the sigmoid stays flatter at the edges and turns faster in the middle.")
sigmoid 999:0.000 1000:0.000 1050:0.091 1100:0.500 1150:0.909 1200:1.000 1201:1.000
linear 999:0.000 1000:0.000 1050:0.250 1100:0.500 1150:0.750 1200:1.000 1201:1.000
Both are exactly 0 before the window and exactly 1 after it.
Inside, the sigmoid stays flatter at the edges and turns faster in the middle.
[8]:
import matplotlib.pyplot as plt
def ramp_over_window(transition, width=200):
"""P(new concept) across a drift, sampled either side of the window."""
stream = DriftStream(
stream=[
Concept(SEA(function=1), num_instances=1000),
GradualDrift(num_instances=width, transition_function=transition),
Concept(SEA(function=3), num_instances=1000),
]
)
node, drift = stream._root, stream.get_drifts()[0]
xs = range(drift.start - 50, drift.end + 51)
return drift, xs, [node.probability_of_new_concept(n) for n in xs]
plt.figure(figsize=(9, 4))
for transition, label in (("sigmoid", "sigmoid (default)"), ("linear", "linear")):
drift, xs, ys = ramp_over_window(transition)
plt.plot(list(xs), ys, label=label)
plt.axvline(drift.start, color="grey", ls=":", lw=1)
plt.axvline(drift.end, color="grey", ls=":", lw=1)
plt.text(drift.start, 1.05, " window", color="grey", va="bottom")
plt.xlabel("instance")
plt.ylabel("P(instance from the new concept)")
plt.title("Both transitions start and finish with the window")
plt.legend()
plt.show()
Why the window always contains the transition#
A sigmoid never truly reaches 0 or 1, so left alone it would keep drawing from the old concept long after the drift was supposed to be over.
CapyMOA parametrises it from the width instead, so the curve completes inside the window, and clips the last sliver at the edges. In practice that means the transition finishes exactly where you said it would.
This is a choice, not a limitation of the shape. If you want a transition that lingers, ask for a wider window rather than a curve that overruns a narrow one - the window is the thing the rest of the definition is built around.
A custom function is clipped the same way, so it should be written to complete over
0.0to1.0of the window.
Supplying your own transition#
transition_functionalso accepts a callable. It receives progress through the window -0.0at the start,1.0at the end – and returns the probability that the instance comes from the new concept.The result is clipped to the window: a function that has not reached 1 by the end is cut off there. If you want a longer transition, widen the window rather than stretching the function.
[9]:
# A transition that holds off, then switches quickly near the end.
def late_switch(progress):
return progress**3
stream_custom = DriftStream(
stream=[
Concept(SEA(function=1), num_instances=1000),
GradualDrift(num_instances=200, transition_function=late_switch),
Concept(SEA(function=3), num_instances=1000),
]
)
print(stream_custom.describe())
# And the effect on where the instances come from, against the default.
stream_default = DriftStream(
stream=[
Concept(SEA(function=1), num_instances=1000),
GradualDrift(num_instances=200),
Concept(SEA(function=3), num_instances=1000),
]
)
print("counts with the default sigmoid:", stream_default.get_concept_counts())
print("counts with progress**3 :", stream_custom.get_concept_counts())
DriftStream over 2200 instances, range form
horizon: from the declared lengths
concept declared drawn in drift share
SEA(function=1) 1000 1154 154 52.5%
SEA(function=3) 1000 1046 46 47.5%
drifts
GradualDrift(position=1100, start=1000, end=1200, width=200)
counts with the default sigmoid: [1102, 1098]
counts with progress**3 : [1154, 1046]
4.3 RecurrentConceptDriftStream#
Concepts often return: a weekday pattern, a seasonal effect, a fault mode that reappears. RecurrentConceptDriftStream builds a stream that cycles through a list of concepts rather than passing through each one once.
The stream it produces is an ordinary DriftStream, so everything above applies: the drift metadata, describe(), and conversion to MOA. An example of the MOA side is in Tutorial 6: Exploring Advanced Features, §6.6.
Reference:
Gunasekara, N., Pfahringer, B., Gomes, H. M., Bifet, A., & Koh, Y. S. (2024). Recurrent concept drifts on data streams. International Joint Conferences on Artificial Intelligence Organization
4.3.1 Generate a stream with recurrent concepts#
In this example, we are simply copying and pasting rather than using RecurrentConceptDriftStream to demonstrate that it is possible (but a bit long and might be error prone)
[13]:
from capymoa.stream.drift import DriftStream, AbruptDrift
from capymoa.stream.generator import RandomTreeGenerator
from capymoa.classifier import OnlineBagging
from capymoa.evaluation import prequential_evaluation
from capymoa.evaluation.visualization import plot_windowed_results
window_size = 1000
concept_length = 2000
max_instances = concept_length * 6
stream_with_drifts = DriftStream(
stream=[
RandomTreeGenerator(tree_random_seed=1),
AbruptDrift(position=concept_length * 1),
RandomTreeGenerator(tree_random_seed=2),
AbruptDrift(position=concept_length * 2),
RandomTreeGenerator(tree_random_seed=3),
AbruptDrift(position=concept_length * 3),
RandomTreeGenerator(tree_random_seed=1, instance_random_seed=2),
AbruptDrift(position=concept_length * 4),
RandomTreeGenerator(tree_random_seed=2, instance_random_seed=2),
AbruptDrift(position=concept_length * 5),
RandomTreeGenerator(tree_random_seed=3, instance_random_seed=2),
]
)
OB = OnlineBagging(schema=stream_with_drifts.get_schema(), ensemble_size=10)
results_stream_with_drifts_OB = prequential_evaluation(
stream=stream_with_drifts,
learner=OB,
window_size=window_size,
max_instances=max_instances,
)
print(f"Recurrent concept stream CapyMOA:\n{stream_with_drifts}")
plot_windowed_results(results_stream_with_drifts_OB, metric="accuracy")
Recurrent concept stream CapyMOA:
RTG(),AbruptDrift(position=2000),RTG(tree_random_seed=2),AbruptDrift(position=4000),RTG(tree_random_seed=3),AbruptDrift(position=6000),RTG(instance_random_seed=2),AbruptDrift(position=8000),RTG(instance_random_seed=2, tree_random_seed=2),AbruptDrift(position=10000),RTG(instance_random_seed=2, tree_random_seed=3)
4.3.2 Use recurrent concept drift API to generate recurrent concepts#
The RecurrentConceptDriftStream API adds concept meta information for plotting which is not available in the previous example.
[14]:
from capymoa.stream.drift import AbruptDrift, GradualDrift, RecurrentConceptDriftStream
from capymoa.stream.generator import LEDGeneratorDrift
from capymoa.classifier import HoeffdingTree
from capymoa.evaluation import prequential_evaluation
from capymoa.evaluation.visualization import plot_windowed_results
# We declare all the concepts
concept1 = LEDGeneratorDrift(number_of_attributes_with_drift=1, instance_random_seed=1)
concept2 = LEDGeneratorDrift(number_of_attributes_with_drift=3, instance_random_seed=1)
concept3 = LEDGeneratorDrift(number_of_attributes_with_drift=5, instance_random_seed=1)
concept4 = LEDGeneratorDrift(number_of_attributes_with_drift=7, instance_random_seed=1)
window_size = 1000
concept_length = 2000
concept_transition_width = 50
max_recurrences_per_concept = 2
concept_list = [concept1, concept2, concept3, concept4]
concept_name_list = ["concept1", "concept2", "concept3", "concept4"]
max_instances = concept_length * len(concept_list) * max_recurrences_per_concept
stream_with_recurrent_concepts = RecurrentConceptDriftStream(
concept_list=concept_list,
max_recurrences_per_concept=max_recurrences_per_concept,
# transition_type_template=AbruptDrift(position=concept_length), # we could use AbruptDrift as well
transition_type_template=GradualDrift(
position=concept_length, width=concept_transition_width
),
concept_name_list=concept_name_list,
)
ll = HoeffdingTree(schema=stream_with_recurrent_concepts.get_schema())
results_stream_with_drifts_OB = prequential_evaluation(
stream=stream_with_recurrent_concepts,
learner=ll,
window_size=window_size,
max_instances=max_instances,
)
plot_windowed_results(results_stream_with_drifts_OB, metric="accuracy")
4.4 Working with MOA directly#
DriftStream composes concepts in Python, so a concept can be any Stream - including NumpyStream, CSVStream and others MOA cannot represent.
Full MOA interoperability is still there for those who want it:
to_moa_stream()converts aDriftStreaminto the equivalent nested MOAConceptDriftStream, when every concept is MOA-backed.A
DriftStreamcan also be defined from a MOA CLI instead of a list of concepts.
Both are shown in Tutorial 6: Exploring advanced features, §6.6, alongside the raw MOA syntax for the same streams.
[ ]: