New

Real-Time Anomaly Detection

Notebook


SingleStore Notebooks

Real-Time Anomaly Detection

Note

This notebook can be run on a Free Starter Workspace. To create a Free Starter Workspace navigate to + using the left nav. You can also use your existing Standard or Premium workspace with this Notebook.

A field of weather sensors reports wind, rain and temperature around the clock. Almost every reading is unremarkable, and there are far too many to look at — the historical file loaded below holds about a million of them, with new ones arriving continuously. The readings worth an alert are the few that break the pattern: a wind value that spikes while nothing else moves, a temperature that shifts further in an hour than it usually does in a month. You cannot write a rule for every sensor in advance, and you cannot eyeball a million rows. This notebook builds something that decides for you, in SQL and Python, with no trained model.

The idea: turn each reading of wind, rain and temperature into a vector, then label a new reading by finding the readings it most resembles and taking the label they already carry. Similar readings get similar vectors, so "unusual" becomes a distance you can compute — and computing it is a dot_product() query, so it happens inside the database, next to the data.

What you will do:

  1. Load a million labelled historical readings and a stream of new ones from S3 using pipelines, SingleStore's built-in ingest.

  2. Turn the new readings into vectors with UMAP.

  3. Score each new reading against the whole history with dot_product(), let its twenty nearest neighbours vote on a label, and write the result back.

  4. Watch the result in two dashboards — anomaly trends over time with Plotly, and a live table that refreshes as rows land.

No prior experience with vectors is needed. If you have used a SELECT, you can follow the scoring query.

Architecture Diagram

Database Setup

Two tables hold everything in this notebook:

  • sensor_data_with_vectors — the history you score against. Each row is one reading: date, city, longitude, latitude, the three sensor values, the anomaly label the reading carries, and the embeddings vector that represents it.

  • sensor_data_stage — the landing table for new, unlabelled readings. Same columns, no anomaly.

The source dataset is French, so the sensor columns are named vent, pluie and temp — wind, rain and temperature. The anomaly column reuses those same three words to say which channel broke the pattern in that reading, or none if nothing did. The column names are left as they are so they match the CSV files on S3, but the charts and the live dashboard later on display them in English.

The cell below creates the database. A Free Starter Workspace already has one and does not allow creating another, so it checks is_shared_tier first and only runs DROP DATABASE / CREATE DATABASE on a Standard or Premium Workspace. On a Free Starter Workspace the tables are created in the database you already have.

In [1]:

1shared_tier_check = %sql show variables like 'is_shared_tier'2if not shared_tier_check or shared_tier_check[0][1] == 'OFF':3    %sql DROP DATABASE IF EXISTS iot_sensor_db;4    %sql CREATE DATABASE iot_sensor_db;

Action Required

If you are using a Standard or Premium Workspace, select the iot_sensor_db database from the drop-down menu at the top of this notebook before running the cells below. On a Free Starter Workspace, leave your existing database selected.

In [2]:

1%%sql2CREATE TABLE IF NOT EXISTS sensor_data_with_vectors (3  date DATETIME,4    city VARCHAR(50),5    longitude VARCHAR(50),6    latitude VARCHAR(50),7  vent FLOAT(8,2),8  pluie FLOAT(8,2),9  temp FLOAT(8,2),10  anomaly VARCHAR(10),11  embeddings text12);13
14CREATE TABLE IF NOT EXISTS sensor_data_stage (15    city VARCHAR(50),16    longitude VARCHAR(50),17    latitude VARCHAR(50),18    vent FLOAT(8,2),19    pluie FLOAT(8,2),20    temp FLOAT(8,2),21    embeddings text,22    date DATETIME23);

Loading the Data with Pipelines

Two pipelines feed the tables, both reading CSV from a public S3 bucket:

  • sensor_data_pipeline → sensor_data_with_vectors, the roughly one million historical readings that already carry a label and an embedding.

  • sensor_realtime_data_pipeline → sensor_data_stage, the new readings to be labelled. It sets date = NOW() as the rows arrive, so they look like they just happened.

Why this is a pipeline and not a load script

If you are coming from another database, this step is usually the one that lives outside it. You write a loader — a script, a scheduled job, a managed ingest service — that reads the bucket, parses the rows, and issues inserts. It is a separate thing to deploy, secure, schedule, monitor and pay for.

A pipeline is a database object instead. You create it with DDL, exactly like a table, and the cluster itself runs it. It is stored in the database, included in backups, and survives a restart. What makes that fast:

  • Every partition pulls its own data. Nothing is funneled through a single coordinator. Each partition connects to the source, claims its own slice, parses it, and writes its local rows — so throughput scales with partitions rather than with one loader process.

  • There is no intermediate copy. Rows go from the source straight into the target table.

  • Progress is tracked transactionally. Committed files and offsets are recorded in the same transaction as the data, so a stopped pipeline resumes exactly where it left off without duplicating or skipping rows. That is why CREATE OR REPLACE PIPELINE is safe to re-run here.

  • It can transform in flight. SET date = NOW() below is doing that, with no second pass over the table. Column subsets, expressions and computed columns work the same way.

  • It keeps watching. FOREGROUND, used here, runs the load to completion and blocks — what you want in a notebook, so the next cell has data. BACKGROUND leaves the pipeline running, ingesting new files or new messages on a topic continuously with nobody triggering a job.

Watch it work in the next few cells: a million rows land in a few seconds, even on a Free Starter Workspace. You monitor it in SQL like anything else — SHOW PIPELINES, or the information_schema pipeline views for per-batch detail.

In [3]:

1%%sql2
3CREATE OR REPLACE PIPELINE sensor_data_pipeline AS4LOAD DATA S3 's3://s2db-demos-pub-bucket/real-time-anomaly-detection-demo/demothon/with_cities_embeddings.csv'5INTO TABLE `sensor_data_with_vectors`6FIELDS TERMINATED BY ','7ENCLOSED BY '"'8LINES TERMINATED BY '\n'9IGNORE 1 LINES;

In [4]:

1%%sql2START PIPELINE sensor_data_pipeline FOREGROUND;

In [5]:

1%%sql2SELECT * FROM sensor_data_with_vectors limit 2;

In [6]:

1%%sql2CREATE OR REPLACE PIPELINE sensor_realtime_data_pipeline AS3LOAD DATA S3 's3://s2db-demos-pub-bucket/real-time-anomaly-detection-demo/demothon/demo_day_data2.csv'4INTO TABLE `sensor_data_stage`5(city, longitude, latitude, vent, pluie, temp, embeddings)6FIELDS TERMINATED BY ','7ENCLOSED BY '"'8LINES TERMINATED BY '\r\n'9IGNORE 1 LINES10SET date = NOW();

In [7]:

1%%sql2START PIPELINE sensor_realtime_data_pipeline FOREGROUND;

In [8]:

1%%sql2SELECT * FROM sensor_data_stage limit 1;

Data Preparation

Install UMAP, read the new readings out of sensor_data_stage into a DataFrame, and fill or drop null values so the embedding step has clean input.

UMAP (Uniform Manifold Approximation and Projection) is the dimensionality reduction library used in the next section.

In [9]:

1!pip install umap-learn --quiet

Note

Restart Kernel if importing umap gives error

In [10]:

1import umap2from sklearn.preprocessing import normalize3
4import sqlalchemy5from sqlalchemy import create_engine6import json7import pandas as pd

In [11]:

1# Filling null values usingbfill()2
3engine = create_engine(connection_url)4
5df = pd.read_sql('select * from sensor_data_stage', engine)6
7df = df.bfill(axis=0)8
9df = df.dropna()

Generating Vector Embeddings with UMAP

Take a slice of the new readings, and turn the three sensor values — vent, pluie, temp (wind, rain, temperature) — into a 15-dimensional vector each.

UMAP places readings that behave alike near each other in the new space, which is the property the scoring step depends on. The vectors are then L2-normalized, so dot_product() on a pair of them is their cosine similarity: 1.0 for identical, near 0 for unrelated.

In [12]:

1# code to generate embeddings for real time data2new_df1 = df.iloc[50:100].copy()3features = new_df1[['vent', 'pluie', 'temp']]4
5reducer = umap.UMAP(n_components=15)6embeddings = reducer.fit_transform(features)

In [13]:

1normalized_embeddings = normalize(embeddings, norm='l2')2new_df1['embeddings'] = list(normalized_embeddings)

In [14]:

1new_df1.head()

Anomaly Detection with dot_product

For each new reading, one query does the work: dot_product() scores its embedding against every vector in sensor_data_with_vectors, the top 20 most similar rows are grouped by their anomaly label, and the label with the most votes wins. So the answer for a reading is one of vent, pluie or temp — wind, rain or temperature was the channel out of line — or none if the neighbours say the reading was ordinary. Readings with no similar history get a default value.

Note where the work happens — the scan, the sort and the cut to 20 all run in the database, so what crosses the network is a label, not a table of candidates.

The labelled rows are then cast to the table's column types and appended to sensor_data_with_vectors, which makes them part of the reference set for the next reading.

In [15]:

1new_df = df.iloc[50:70].copy()2# iterate over each row in the new DataFrame3for index, row in new_df.iterrows():4    # get the embeddings from the current row5    embeddings = row['embeddings']6
7    # convert numpy array to list and then to a JSON string8    embeddings_json = json.loads(embeddings)9
10    # create the query string11    query = f"""12           SELECT anomaly, COUNT(anomaly) as count13            FROM (14                SELECT anomaly, dot_product(15                    JSON_ARRAY_PACK('{embeddings_json}'),16                    JSON_ARRAY_PACK(sensor_data_with_vectors.embeddings)17                ) AS similarity18                FROM sensor_data_with_vectors19                ORDER BY similarity DESC20                LIMIT 2021            ) AS subquery22            GROUP BY anomaly23            ORDER BY count DESC;24    """25
26    # execute the query27    result = pd.read_sql_query(query, con=engine)28
29    # check if the result is empty30    if not result.empty:31        # append the result to the current row in the new DataFrame32        new_df.loc[index, 'anomaly'] = result['anomaly'].values[0]33    else:34        # set anomaly to None or some default value35        new_df.loc[index, 'anomaly'] = 'none'

In [16]:

1new_df.head()

In [17]:

1# appending the new dataframe to main the table : sensor_data_with_vectors2new_df['date'] = pd.to_datetime(new_df['date'])3new_df['city'] = new_df['city'].astype(str)4new_df['longitude'] = new_df['longitude'].astype(str)5new_df['latitude'] = new_df['latitude'].astype(str)6new_df['vent'] = new_df['vent'].astype(float)7new_df['pluie'] = new_df['pluie'].astype(float)8new_df['temp'] = new_df['temp'].astype(float)9new_df['anomaly'] = new_df['anomaly'].astype(str)10new_df['embeddings'] = new_df['embeddings'].astype(str)11
12# Append data to SQL table13new_df.to_sql('sensor_data_with_vectors', con=engine, if_exists='append', index=False)

Dashboard: Anomalies over Time

Read the labelled rows back (skipping none), group them by day and anomaly type, and plot them with Plotly — first every city together, then one chart per city.

The stored labels are French, so they are translated to wind, rain and temperature for the chart legend. Each colour is therefore one sensor channel, and the height of a band is how often that channel was the one that looked wrong.

In [18]:

1import pandas as pd2import plotly.express as px3from sqlalchemy import create_engine4engine = create_engine(connection_url)

In [19]:

1# df = pd.read_sql('select * from sensor_data_with_vectors limit 50000;', engine)2df = pd.read_sql("select * from sensor_data_with_vectors where anomaly <> 'none' limit 50000;", engine)

In [20]:

1# The source data is French: vent/pluie/temp name the sensor that broke the pattern.2ANOMALY_LABELS = {'vent': 'wind', 'pluie': 'rain', 'temp': 'temperature'}3df['anomaly'] = df['anomaly'].replace(ANOMALY_LABELS)4
5df['date'] = pd.to_datetime(df['date'])6df['date_only'] = df['date'].dt.date7# Group data by date and anomaly, then count the instances8grouped_df = df.groupby(['date_only', 'anomaly']).size().reset_index(name='counts')9
10# Create line plot with Plotly11fig = px.histogram(grouped_df, x='date_only', y='counts', color='anomaly',12              title='Anomalies over Time', labels={'date_only': 'Date', 'counts': 'Anomaly Count'})13
14# Show plot15fig.show()

In [21]:

1# Group data by date, city and anomaly, then count the instances2grouped_df = df.groupby(['date_only', 'city', 'anomaly']).size().reset_index(name='counts')3
4# List your cities5cities = ['Washington DC', 'New York', 'Los Angeles'] # Add or change according to your DataFrame6
7# Create a separate plot for each city8for city in cities:9    city_df = grouped_df[grouped_df['city'] == city]10    fig = px.histogram(city_df, x='date_only', y='counts', color='anomaly',11                  title=f'Anomalies over Time for {city}', labels={'date_only': 'Date', 'counts': 'Anomaly Count'})12    fig.show()

Real-Time Anomaly Detection Dashboard

display_table_contents() re-queries sensor_data_with_vectors every 10 seconds and redraws the 50 most recent readings as a rich table, colour-coded by anomaly type — the same view an operator would keep open.

Action Required

The cell below refreshes forever. It is a live dashboard, not a cell that finishes: it re-queries every 10 seconds and redraws the table. Interrupt the kernel to move on (Kernel > Interrupt, or the stop button). Leaving it running for more than about an hour can also fail with 2628: JWT token expired, because the connection URL the notebook hands you carries a token that expires.

In [22]:

1!pip install tabulate pymysql Ipython rich --quiet

In [23]:

1import time2import os3import shutil4import pymysql5from rich.console import Console6from rich.table import Table7from rich import box8from IPython.display import clear_output

In [24]:

1from sqlalchemy import create_engine2import pandas as pd3
4ANOMALY_LABELS = {'vent': 'wind', 'pluie': 'rain', 'temp': 'temperature'}5
6
7def display_table_contents():8    # Create a database engine9    engine = create_engine(connection_url)10
11    # Execute query to fetch initial table contents12    query = 'SELECT date, vent, pluie, temp, anomaly FROM sensor_data_with_vectors ORDER BY date DESC'13    table_data = pd.read_sql_query(query, engine)14
15    while True:16        # Clear console output17        clear_output(wait=True)18
19        # Create a console instance20        console = Console()21
22        # Get the terminal width23        terminal_width = shutil.get_terminal_size().columns24
25        # Print the title with centered alignment26        title = "[bold magenta]REAL TIME ANALYTICS DASHBOARD[/bold magenta]"27        console.print(title.center(terminal_width))28
29        # Create a table instance30        table = Table(show_header=True, header_style="bold", box=None)31
32        # Add columns to the table33        table.add_column("Date", justify="center", style="cyan", width=terminal_width // 5 + 5)34        table.add_column("Wind", justify="center", style="magenta", width=terminal_width // 5)35        table.add_column("Rain", justify="center", style="yellow", width=terminal_width // 5)36        table.add_column("Temperature", justify="center", style="green", width=terminal_width // 5)37        table.add_column("Anomaly", justify="center", width=terminal_width // 5)38
39        # Add rows to the table40        for row in table_data.head(50).itertuples(index=False):41            # Convert datetime to string before adding to the table42            formatted_row = [str(cell) for cell in row]43
44            # Check the anomaly type, and show it in English45            anomaly_type = formatted_row[4]46            formatted_row[4] = ANOMALY_LABELS.get(anomaly_type, anomaly_type)47
48            # Determine the style based on the anomaly type49            if anomaly_type == 'pluie':50                style = "bold blue"51            elif anomaly_type == 'vent':52                style = "bold magenta"53            elif anomaly_type == 'temp':54                style = "bold green"55            else:56                style = ""57
58            # Add the row with the appropriate style59            table.add_row(*formatted_row, style=style)60
61        # Print the table62        console.print(table)63
64        # Wait for 30 seconds before refreshing65        time.sleep(10)66
67        # Execute query to fetch updated table contents68        updated_data = pd.read_sql_query(query, engine)69
70        # Update the table_data with the fetched data71        table_data = updated_data72
73# Call the function to start displaying the table contents74display_table_contents()

Conclusion

What you built

A closed loop that labels new sensor readings by comparing them to readings that have already been judged.

Two pipelines loaded from S3: one filled sensor_data_with_vectors with historical readings that already carry an anomaly label and a vector embedding, the other filled sensor_data_stage with fresh readings stamped date = NOW(). For each new reading, dot_product() scored its embedding against every stored vector, the twenty nearest neighbours voted, and the winning label was written back into the history table. The dashboard then read that table live, so a row you labelled a moment ago is part of the reference set for the row after it.

There is no trained model here, and that is the point. Nothing was fitted, no thresholds were tuned, and no retraining step exists. "Anomalous" is defined entirely by what similar readings were called before, which means the definition improves as the labelled history grows rather than going stale between training runs.

Why it runs where the data lives

The scoring query never leaves the database. Each new reading is compared against the whole history, sorted, and cut to twenty rows before anything crosses the network, so what comes back is one label rather than a table of candidates. A few reasons that stays quick as the history grows:

  • Queries are compiled to machine code. The per-row work in a dot_product scan is a tight machine-code loop, not an interpreter walking an expression tree once per row, and the compiled plan is cached for reuse.

  • Vector math uses SIMD instructions. A CPU can multiply and accumulate several floats in a single instruction, which is exactly the shape of a similarity score.

  • Partitions scan in parallel. The history is spread across partitions that each score their own slice and return only their best candidates to be merged. More compute means more throughput with no change to the query.

  • Ingest and analytics hit the same tables. The pipelines write while the dashboard reads. There is no export step, no second system to keep in sync, and no window where the newest readings are invisible to scoring.

Where to take it next

  • Swap S3 for a real stream. LOAD DATA KAFKA replaces LOAD DATA S3 and the rest of the notebook is unchanged, which is the lower panel of the architecture diagram.

  • Add a vector index. At this size scoring every row is cheap. As the history grows into the millions, an index lets SingleStore approximate the nearest neighbours instead of scanning all of them, trading a measurable amount of recall for a much shorter scan.

  • Use the labels, not just the vote. The twenty neighbours carry a similarity score. A weak top score is a reading unlike anything seen before, which is arguably the more interesting alert.

  • Store the vectors as VECTOR. These tables keep embeddings in a TEXT column and unpack them with JSON_ARRAY_PACK at query time. A native VECTOR column skips that conversion and is what a new table should use.

Cleanup

The cell above runs until you stop it, so interrupt the kernel before continuing.

On a Standard or Premium Workspace the cell below drops iot_sensor_db, which removes both tables and both pipelines with it. A Free Starter Workspace cannot drop its own database, so there the same cell drops the two pipelines and the two tables instead — worth doing, because the historical load is around a million rows and sensor_data_with_vectors grows every time you re-run the labelling step. To release a Free Starter Workspace entirely, terminate it from the UI.

In [25]:

1shared_tier_check = %sql show variables like 'is_shared_tier'2if not shared_tier_check or shared_tier_check[0][1] == 'OFF':3    %sql DROP DATABASE IF EXISTS iot_sensor_db;4else:5    %sql DROP PIPELINE IF EXISTS sensor_data_pipeline;6    %sql DROP PIPELINE IF EXISTS sensor_realtime_data_pipeline;7    %sql DROP TABLE IF EXISTS sensor_data_with_vectors;8    %sql DROP TABLE IF EXISTS sensor_data_stage;

Details


About this Template

Real-time anomaly detection in IoT sensor data, harnessing the robust capabilities of SingleStoreDB and advanced analytical techniques.

This Notebook can be run in Shared Tier, Standard and Enterprise deployments.

Tags

vectordbrealtime

See Notebook in action

Launch this notebook in SingleStore and start executing queries instantly.

License

This Notebook has been released under the Apache 2.0 open source license.