100 Exercises / anomaly detection / Abnormality detection: 100 Exercises

Manufacturing Anomaly Detection in Practice with Python | Eight Machine Learning Methods and Threshold Design

Capturing Early Signs in Injection Molding Equipment: Multivariate Anomaly Detection with 10 Exercises Using Machine Learning (No.051–No.060)

Overview

If temperature, vibration, current, and hydraulic pressure are monitored only at individual upper limits, equipment deterioration is missed—‘all values are within the upper limit, but the combinations are unnatural.’ In this article, we use a hypothetical injection molding line as a subject, examining six unsupervised learning streams, model comparison, and threshold design for anomaly scores as a single decision-making process.

The goal is not just to choose the model with the highest precision. Establishing monitoring rules that can be maintained on-site, taking into account the number of inspections that can be inspected, losses from missed items, explainability, and the operational burden of relearning..

[!NOTE] This material is a notebook previously used by Surikoubo (or personally by the representative, Kazuyama), and has been reconstructed, edited, and published with the company’s permission.
All data listed is fictional and has no relation whatsoever to real companies, factories, or figures.

Introduction: Practical Challenges in Manufacturing Covered in This Article

At injection molding facility A-01, sensor values are collected for each shot of the product. The goal of maintenance personnel is not to definitively identify failures, but to narrow down shots that differ from usual and to identify inspection targets during planned shutdowns.

The output of the anomaly detection model is treated as a Primary screening to prioritize inspections, not as a facility shutdown command. Scores can also be high due to sensor calibration defects, product switching, or startup conditions, so cross-checking with work history and quality results is essential.

Common situations on site

  • There is a large amount of normal data, but few fault labels and definitions fluctuate
  • With the management limits of a single sensor, it is impossible to detect the breakdown of relationships among multiple variables.
  • There is a limit to the number of inspection personnel, and if alerts are too frequent, operations become mere formality
  • The distribution of ‘normal’ varies depending on variety, equipment, season, and post-conservation

Why is this issue so difficult to judge?

Unsupervised anomaly detection does not directly learn the correct label, but determines the degree of anomaly based on data sparseness, boundaries, and distribution. Therefore, even with the same data, candidates vary depending on the method and hyperparameters. Also, statistically unusual phenomena and operational abnormalities that lead to stoppages or defects are not synonymous.

Overview of Exercise covered this time

No.Methods and IssuesOn-site Verification
051Isolation ForestShots prone to isolation in multivariate spaces
052Local Outlier FactorLocally less frequent than peripheral shots
053One-Class SVMFlexible boundaries of the normal domain
054Elliptic EnvelopeElliptical normal region considering covariance
055k Near-DistanceTransparency anomaly depending on the distance to the nearby area
056clusteringHandling of small groups or points far from the center
057KMeansCandidate selection based on distance to the nearest center
058DBSCANNoise points in low-density regions
059Model ComparisonComparison of detection performance, number of cases, and consensus
060threshold adjustmentReflecting inspection capability and missed costs

Preparing the Python environment

Data is handled with numpy and pandas, utilizing each model of scikit-learn. The graph uses only matplotlib. For reproducibility, the random number seed is fixed at 42.

import warnings
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import sklearn

from sklearn.cluster import DBSCAN, KMeans
from sklearn.covariance import EllipticEnvelope
from sklearn.ensemble import IsolationForest
from sklearn.metrics import precision_score, recall_score, f1_score
from sklearn.neighbors import LocalOutlierFactor, NearestNeighbors
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import OneClassSVM

warnings.filterwarnings("ignore", category=UserWarning, module="matplotlib")
SEED = 42
np.random.seed(SEED)
pd.set_option("display.max_columns", 20)

print(f"numpy       : {np.__version__}")
print(f"pandas      : {pd.__version__}")
print(f"matplotlib  : {matplotlib.__version__}")
print(f"scikit-learn: {sklearn.__version__}")
numpy       : 2.5.1
pandas      : 3.0.3
matplotlib  : 3.11.0
scikit-learn: 1.9.0

Creation of Fictional Data

Normally, 900 shots are generated, but 40 shots are required. Normally, as the mold temperature rises, the heater current increases slightly, and it is assumed that vibration and hydraulic pressure are weakly related. The data to be verified includes multiple deterioration patterns, such as increased vibration, disrupted temperature-current relationships, and decreased hydraulic pressure.

known_issue is a label for evaluation that was later identified during inspections and quality checks. It is not used for model training. This is not a complete failure label but a limited audit result for comparison.

rng = np.random.default_rng(SEED)
n_normal, n_issue = 900, 40

mean = np.array([210.0, 2.4, 31.0, 8.0])
cov = np.array([
    [16.0, 0.7, 5.0, 1.0],
    [0.7, 0.36, 0.4, 0.3],
    [5.0, 0.4, 9.0, 1.2],
    [1.0, 0.3, 1.2, 1.0],
])
normal = rng.multivariate_normal(mean, cov, n_normal)

issue_1 = rng.multivariate_normal([211, 5.4, 33, 9.4], np.diag([9, .20, 4, .35]), 14)
issue_2 = rng.multivariate_normal([222, 2.7, 25, 8.2], np.diag([8, .25, 3, .50]), 13)
issue_3 = rng.multivariate_normal([207, 3.6, 32, 5.4], np.diag([7, .22, 4, .20]), 13)
issue = np.vstack([issue_1, issue_2, issue_3])

columns = ["mold_temp_c", "vibration_mm_s", "heater_current_a", "oil_pressure_mpa"]
df = pd.DataFrame(np.vstack([normal, issue]), columns=columns)
df.insert(0, "shot_id", [f"S{i:04d}" for i in range(1, len(df) + 1)])
df["known_issue"] = np.r_[np.zeros(n_normal, dtype=int), np.ones(n_issue, dtype=int)]
X = df[columns]
X_scaled = StandardScaler().fit_transform(X)

print(f"Number of shots: {len(df):,} / Evaluation Confirmation: {df['known_issue'].sum()}")
display(df.head())
display(df.groupby("known_issue")[columns].agg(["mean", "std"]).round(2))
Number of shots: 940 / Points to check for evaluation: 40
shot_id mold_temp_c vibration_mm_s heater_current_a oil_pressure_mpa known_issue
0 S0001 207.579632 2.201556 32.597790 8.998515 0
1 S0002 215.879777 2.994548 37.801656 9.099905 0
2 S0003 209.024388 2.377789 32.812262 9.146080 0
3 S0004 211.071845 2.910164 28.307671 7.929873 0
4 S0005 207.416243 2.676244 32.282850 8.891665 0
mold_temp_c vibration_mm_s heater_current_a oil_pressure_mpa
mean std mean std mean std mean std
known_issue
0 210.04 3.88 2.4 0.59 31.04 2.99 7.98 1.02
1 213.47 6.42 3.9 1.25 30.52 4.45 7.63 1.75
fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
colors = np.where(df["known_issue"].eq(1), "tab:red", "tab:blue")
axes[0].scatter(df["mold_temp_c"], df["heater_current_a"], c=colors, alpha=.55, s=20)
axes[0].set_title("Temperature vs. heater current")
axes[0].set_xlabel("Mold temperature [C]")
axes[0].set_ylabel("Heater current [A]")
axes[0].grid(True, alpha=.3)
axes[1].scatter(df["vibration_mm_s"], df["oil_pressure_mpa"], c=colors, alpha=.55, s=20)
axes[1].set_title("Vibration vs. oil pressure")
axes[1].set_xlabel("Vibration [mm/s]")
axes[1].set_ylabel("Oil pressure [MPa]")
axes[1].grid(True, alpha=.3)
fig.tight_layout()
plt.show()

png

No.051: Using Isolation Forest

Meaning in Practice

Isolation Forest considers points where features are isolated with few splits when randomly partitioned as anomalies. Because it does not assume a strict distribution of normal states, it is suitable for early PoCs that quickly extract candidates from complex regions created by multiple sensors.

Approach to Analysis and Modeling

If the average path length of point xx is E[h(x)]E[h(x)] and the criterion for average path length at sample size nn is c(n)c(n), the typical anomalies are as follows.

s(x,n)=2E[h(x)]/c(n)s(x,n)=2^{-E[h(x)]/c(n)}

The closer ss is to 1, the more likely it is to isolate. contamination=0.05 is the operational hypothesis that “about 5% is always abnormal,” and is not an estimated failure rate.

Check with Python

iso = IsolationForest(n_estimators=300, contamination=.05, random_state=SEED)
df["score_iso"] = -iso.fit(X_scaled).score_samples(X_scaled)
df["pred_iso"] = (iso.predict(X_scaled) == -1).astype(int)
display(df.nlargest(8, "score_iso")[["shot_id", *columns, "score_iso", "known_issue"]].round(3))

plt.figure(figsize=(8, 4.5))
plt.scatter(df["mold_temp_c"], df["vibration_mm_s"], c=df["score_iso"], cmap="viridis", s=22)
plt.colorbar(label="Isolation score")
plt.title("Isolation Forest anomaly score")
plt.xlabel("Mold temperature [C]")
plt.ylabel("Vibration [mm/s]")
plt.grid(True, alpha=.3)
plt.tight_layout()
plt.show()
shot_id mold_temp_c vibration_mm_s heater_current_a oil_pressure_mpa score_iso known_issue
918 S0919 223.604 3.687 21.653 7.238 0.696 1
126 S0127 220.592 3.633 37.847 11.397 0.672 0
900 S0901 213.905 6.052 37.836 8.578 0.651 1
912 S0913 215.102 5.515 33.103 10.246 0.630 1
907 S0908 214.681 5.932 34.262 9.830 0.629 1
910 S0911 206.332 5.110 36.330 9.347 0.621 1
916 S0917 221.522 2.150 22.260 8.207 0.620 1
926 S0927 223.477 2.998 24.598 8.074 0.620 1

png

Reading the results

The upper ranks include shots with high vibration or those whose temperature-current relationship is far from the normal group. Since the overall score based solely on tree branching cannot determine the cause, the original sensor values from the top row are also recorded so that maintenance personnel can compare them with equipment history.

No.052: Using the Local Outlier Factor

Meaning in Practice

LOF evaluates ‘neglect compared to nearby operating points,’ rather than the overall evaluation. It is effective for finding shots that float only within a single driving group in sites with multiple normal groups depending on the type and load.

Approach to Analysis and Modeling

If the locally reachable density of point pp is lrdk(p)\mathrm{lrd}_k(p), then LOF can be expressed as the density ratio to neighbors.

LOFk(p)=1Nk(p)oNk(p)lrdk(o)lrdk(p)\mathrm{LOF}_k(p)=\frac{1}{|N_k(p)|}\sum_{o\in N_k(p)}\frac{\mathrm{lrd}_k(o)}{\mathrm{lrd}_k(p)}

If it’s roughly 1, it’s about the same as the neighborhood, and if it’s well greater than 1, it’s a local outlier. Sensitivity analysis is performed based on process continuity and lot size.

Check with Python

lof = LocalOutlierFactor(n_neighbors=25, contamination=.05)
df["pred_lof"] = (lof.fit_predict(X_scaled) == -1).astype(int)
df["score_lof"] = -lof.negative_outlier_factor_
display(df.nlargest(8, "score_lof")[["shot_id", *columns, "score_lof", "known_issue"]].round(3))

plt.figure(figsize=(8, 4.5))
plt.scatter(df["vibration_mm_s"], df["oil_pressure_mpa"], c=df["score_lof"], cmap="plasma", s=22)
plt.colorbar(label="LOF score")
plt.title("Local Outlier Factor score")
plt.xlabel("Vibration [mm/s]")
plt.ylabel("Oil pressure [MPa]")
plt.grid(True, alpha=.3)
plt.tight_layout()
plt.show()
shot_id mold_temp_c vibration_mm_s heater_current_a oil_pressure_mpa score_lof known_issue
922 S0923 229.429 2.657 27.339 8.708 2.082 1
918 S0919 223.604 3.687 21.653 7.238 2.026 1
126 S0127 220.592 3.633 37.847 11.397 2.018 0
901 S0902 211.210 6.057 34.612 8.425 1.901 1
900 S0901 213.905 6.052 37.836 8.578 1.887 1
478 S0479 204.327 4.137 36.266 9.103 1.858 0
903 S0904 209.262 5.785 32.073 8.304 1.836 1
907 S0908 214.681 5.932 34.262 9.830 1.832 1

png

Reading the results

Candidates with high LOF density are those with a large difference in surrounding density. Since it is also easy to pick up the normal points at the boundary, check whether the ranking remains stable even when changing n_neighbors. Also, since LOF is generally suitable for outlier search within training data, a separate design using novelty=True is required for scoring new data.

No.053: Using One-Class SVM

Meaning in Practice

One-Class SVM learns boundaries surrounding the majority of conventional data. Using the RBF kernel, it is possible to represent nonlinear normal regions and detect relationship breakdowns that are difficult to detect with simple upper and lower limits.

Approach to Analysis and Modeling

We obtain a hyperplane that separates normal data from the origin in feature space. The conceptual optimization problem is as follows.

minw,ρ,ξ12w2+1νniξiρ\min_{w,\rho,\xi}\frac{1}{2}\|w\|^2+\frac{1}{\nu n}\sum_i\xi_i-\rho

s.t. wϕ(xi)ρξi,ξi0\text{s.t. } w^\top\phi(x_i)\geq\rho-\xi_i,\quad \xi_i\geq0

ν\nu relates to the upper limit of the proportion of outbound points and the lower limit of the proportion of support vectors. Because it is sensitive to scale, standardization is essential.

Check with Python

ocsvm = OneClassSVM(kernel="rbf", gamma="scale", nu=.05)
df["score_ocsvm"] = -ocsvm.fit(X_scaled).decision_function(X_scaled)
df["pred_ocsvm"] = (ocsvm.predict(X_scaled) == -1).astype(int)
display(df.nlargest(8, "score_ocsvm")[["shot_id", *columns, "score_ocsvm", "known_issue"]].round(3))

plt.figure(figsize=(8, 4.5))
plt.hist(df.loc[df.known_issue.eq(0), "score_ocsvm"], bins=35, alpha=.65, label="audit: normal")
plt.hist(df.loc[df.known_issue.eq(1), "score_ocsvm"], bins=20, alpha=.65, label="audit: issue")
plt.axvline(0, color="black", linestyle="--", label="model boundary")
plt.title("One-Class SVM score distribution")
plt.xlabel("Anomaly score (higher = more anomalous)")
plt.ylabel("Shots")
plt.grid(True, alpha=.3)
plt.legend()
plt.tight_layout()
plt.show()
shot_id mold_temp_c vibration_mm_s heater_current_a oil_pressure_mpa score_ocsvm known_issue
126 S0127 220.592 3.633 37.847 11.397 1.347 0
922 S0923 229.429 2.657 27.339 8.708 1.345 1
918 S0919 223.604 3.687 21.653 7.238 1.174 1
157 S0158 211.063 2.070 38.227 10.885 0.730 0
900 S0901 213.905 6.052 37.836 8.578 0.668 1
939 S0940 206.474 3.786 29.456 4.601 0.452 1
933 S0934 214.528 3.633 31.250 5.200 0.419 1
34 S0035 209.778 1.705 28.220 9.928 0.408 0

png

Reading the results

Score 0 is the model boundary, so the front side requires further confirmation. There is overlap in the distribution of normal and need-to-confirm in audits, and it is also clear that models alone cannot be completely separated. Be careful not to make the gamma too large, as the boundary will become finer and will react to normal fluctuations.

No.054: Using Elliptic Envelope

Meaning in Practice

Elliptic Envelope is suitable for processes where normal data is generally distributed in a single-peak, elliptical pattern. To account for covariance, even when temperature and current are within their respective ranges, it can detect states that deviate from normal correlation.

Approach to Analysis and Modeling

Mahalanobis distance using center μ\mu and covariance matrix Σ\Sigma

DM(x)=(xμ)Σ1(xμ)D_M(x)=\sqrt{(x-\mu)^\top\Sigma^{-1}(x-\mu)}

and points with large distances as abnormal candidates. In highly multimodal processes, the assumption of a single ellipse is broken.

Check with Python

ell = EllipticEnvelope(contamination=.05, random_state=SEED)
df["score_elliptic"] = -ell.fit(X_scaled).decision_function(X_scaled)
df["pred_elliptic"] = (ell.predict(X_scaled) == -1).astype(int)
display(df.nlargest(8, "score_elliptic")[["shot_id", *columns, "score_elliptic", "known_issue"]].round(3))

plt.figure(figsize=(8, 4.5))
plt.scatter(df["mold_temp_c"], df["heater_current_a"], c=df["score_elliptic"], cmap="cividis", s=22)
plt.colorbar(label="Robust distance score")
plt.title("Elliptic Envelope score")
plt.xlabel("Mold temperature [C]")
plt.ylabel("Heater current [A]")
plt.grid(True, alpha=.3)
plt.tight_layout()
plt.show()
shot_id mold_temp_c vibration_mm_s heater_current_a oil_pressure_mpa score_elliptic known_issue
900 S0901 213.905 6.052 37.836 8.578 36.750 1
901 S0902 211.210 6.057 34.612 8.425 36.431 1
903 S0904 209.262 5.785 32.073 8.304 30.518 1
918 S0919 223.604 3.687 21.653 7.238 28.626 1
922 S0923 229.429 2.657 27.339 8.708 28.577 1
905 S0906 211.985 5.871 31.745 8.877 27.169 1
907 S0908 214.681 5.932 34.262 9.830 24.147 1
916 S0917 221.522 2.150 22.260 8.207 20.851 1

png

Reading the results

Candidates whose temperature and current combinations deviate from the usual band score high. While the assumptions are clear and easy to explain, if the focus is divided by product type, the model is divided by product type or alternative methods are considered.

No.055: Calculating Anomaly Using the k-Neighborhood Method

Meaning in Practice

k Neighborhood distance directly shows how close a similar past shot is. The algorithm is relatively transparent, making it easy to present similar shots to maintenance personnel.

Approach to Analysis and Modeling

The normalized point xx and the Euclidean distance near x(k)x_{(k)} kk th are considered anomalies.

sk(x)=xx(k)2s_k(x)=\|x-x_{(k)}\|_2

When the kk is small, it is sensitive to local noise, and when large, it is easier to handle abnormalities even in small normal groups. Here, we use the 10th Nearest Neighbor Distance and consider the top 5% as candidates.

Check with Python

knn = NearestNeighbors(n_neighbors=11).fit(X_scaled)
distances, _ = knn.kneighbors(X_scaled)
df["score_knn"] = distances[:, -1]
knn_threshold = df["score_knn"].quantile(.95)
df["pred_knn"] = (df["score_knn"] >= knn_threshold).astype(int)
display(df.nlargest(8, "score_knn")[["shot_id", *columns, "score_knn", "known_issue"]].round(3))

sorted_score = np.sort(df["score_knn"])
plt.figure(figsize=(8, 4.5))
plt.plot(np.arange(1, len(df) + 1), sorted_score)
plt.axhline(knn_threshold, color="tab:red", linestyle="--", label="95th percentile")
plt.title("Sorted 10-nearest-neighbor distance")
plt.xlabel("Shots sorted by score")
plt.ylabel("10-NN distance")
plt.grid(True, alpha=.3)
plt.legend()
plt.tight_layout()
plt.show()
shot_id mold_temp_c vibration_mm_s heater_current_a oil_pressure_mpa score_knn known_issue
922 S0923 229.429 2.657 27.339 8.708 3.021 1
918 S0919 223.604 3.687 21.653 7.238 2.670 1
900 S0901 213.905 6.052 37.836 8.578 2.457 1
126 S0127 220.592 3.633 37.847 11.397 2.433 0
910 S0911 206.332 5.110 36.330 9.347 2.355 1
903 S0904 209.262 5.785 32.073 8.304 2.189 1
912 S0913 215.102 5.515 33.103 10.246 2.164 1
911 S0912 210.178 5.224 30.045 10.242 2.156 1

png

Reading the results

The point where the distance suddenly increases at the far right is a candidate with few similar histories. While quantile thresholds make it easier to control the number of inspection items, they extract a certain number even if all processes are normal. First, it is positioned as the purpose of creating a candidate list.

No.056: Detecting Data Lost Through Clustering

Meaning in Practice

By using clustering, you can organize major operating states into groups and investigate points far from the center or extremely small groups. However, a small group does not necessarily mean abnormality; it may be a small variety or a legitimate operating condition right after setup.

Approach to Analysis and Modeling

There are mainly two ways to use it for anomaly detection.

  1. Set the anomaly level as the distance from each point to the center of the affiliated cluster
  2. Review clusters with small affiliation scores as rare operating conditions

Clustering is not a fault detector but a tool for organizing data structures. It is necessary to assign meanings including process conditions.

Check with Python

kmeans_review = KMeans(n_clusters=4, n_init=20, random_state=SEED).fit(X_scaled)
df["cluster_review"] = kmeans_review.labels_
cluster_review = df.groupby("cluster_review").agg(
    shots=("shot_id", "size"),
    issue_rate=("known_issue", "mean"),
    temp_mean=("mold_temp_c", "mean"),
    vibration_mean=("vibration_mm_s", "mean"),
    pressure_mean=("oil_pressure_mpa", "mean"),
).round(3)
display(cluster_review)

plt.figure(figsize=(8, 4.5))
for c in sorted(df["cluster_review"].unique()):
    part = df[df["cluster_review"].eq(c)]
    plt.scatter(part["vibration_mm_s"], part["oil_pressure_mpa"], s=22, alpha=.6, label=f"cluster {c}")
plt.title("Operating-state clusters")
plt.xlabel("Vibration [mm/s]")
plt.ylabel("Oil pressure [MPa]")
plt.grid(True, alpha=.3)
plt.legend()
plt.tight_layout()
plt.show()
shots issue_rate temp_mean vibration_mean pressure_mean
cluster_review
0 213 0.038 207.104 1.957 6.738
1 211 0.066 212.647 3.125 9.144
2 254 0.071 213.386 2.451 7.632
3 262 0.000 207.592 2.367 8.339

png

Reading the results

By looking at the number of cases by cluster and the rate requiring audit confirmation, you can organize which driving groups should be prioritized for review. However, instead of making the group with a high confirmation rate the stop condition as is, we first check whether the product type, mold, and working conditions are uneven.

No.057: Extracting anomalous candidates with KMeans

Meaning in Practice

KMeans creates a representative driving center of gravity and extracts shots far from any center of gravity. Even if there are multiple steady operation modes, the distance from the overall average may match the actual conditions.

Approach to Analysis and Modeling

KMeans sum of squares within the cluster

i=1nminj{1,,K}xiμj22\sum_{i=1}^{n}\min_{j\in\{1,\ldots,K\}}\|x_i-\mu_j\|_2^2

Minimize it. After learning, s(x)=minjxμj2s(x)=\min_j\|x-\mu_j\|_2 is considered abnormal. The number of clusters KK and quantile thresholds are determined based on the number of operating modes and inspection capability.

Check with Python

kmeans = KMeans(n_clusters=4, n_init=20, random_state=SEED).fit(X_scaled)
distance_matrix = kmeans.transform(X_scaled)
df["score_kmeans"] = distance_matrix.min(axis=1)
kmeans_threshold = df["score_kmeans"].quantile(.95)
df["pred_kmeans"] = (df["score_kmeans"] >= kmeans_threshold).astype(int)
display(df.nlargest(8, "score_kmeans")[["shot_id", *columns, "score_kmeans", "known_issue"]].round(3))

plt.figure(figsize=(8, 4.5))
plt.scatter(df["mold_temp_c"], df["vibration_mm_s"], c=df["score_kmeans"], cmap="magma", s=22)
plt.colorbar(label="Distance to nearest centroid")
plt.title("KMeans distance-based anomaly score")
plt.xlabel("Mold temperature [C]")
plt.ylabel("Vibration [mm/s]")
plt.grid(True, alpha=.3)
plt.tight_layout()
plt.show()
shot_id mold_temp_c vibration_mm_s heater_current_a oil_pressure_mpa score_kmeans known_issue
918 S0919 223.604 3.687 21.653 7.238 4.484 1
900 S0901 213.905 6.052 37.836 8.578 4.456 1
922 S0923 229.429 2.657 27.339 8.708 4.304 1
901 S0902 211.210 6.057 34.612 8.425 4.285 1
907 S0908 214.681 5.932 34.262 9.830 4.114 1
903 S0904 209.262 5.785 32.073 8.304 4.020 1
905 S0906 211.985 5.871 31.745 8.877 4.001 1
916 S0917 221.522 2.150 22.260 8.207 3.709 1

png

Reading the results

Recently, shots farther from the center of gravity have been ranked higher. Since the center of gravity is average, it is affected by outliers. In production, obvious sensor failures are excluded through pretreatment, and if the product composition changes, a reassessment of the center of gravity is necessary.

No.058: Detecting Noise Points with DBSCAN

Meaning in Practice

DBSCAN classifies clusters of sufficiently dense areas and judges them as noise if they do not belong to any densely packed area. It is characterized by the ability to handle non-spherical operating areas without pre-deciding on the number of clusters.

Approach to Analysis and Modeling

Points with min_samples or more points within radius ε\varepsilon are considered core points, and density-reachable points are grouped into the same cluster. If the eps is too small, it triggers excessive alerts; if too large, abnormalities are absorbed into the normal group. Set at the standardized distance.

Check with Python

dbscan = DBSCAN(eps=.62, min_samples=12).fit(X_scaled)
df["dbscan_label"] = dbscan.labels_
df["pred_dbscan"] = (df["dbscan_label"] == -1).astype(int)
dbscan_summary = df.groupby("dbscan_label").agg(shots=("shot_id", "size"), issue_rate=("known_issue", "mean")).round(3)
display(dbscan_summary)

plt.figure(figsize=(8, 4.5))
plot_colors = np.where(df["pred_dbscan"].eq(1), "tab:red", "tab:blue")
plt.scatter(df["vibration_mm_s"], df["oil_pressure_mpa"], c=plot_colors, alpha=.6, s=22)
plt.title("DBSCAN noise detection (red = noise)")
plt.xlabel("Vibration [mm/s]")
plt.ylabel("Oil pressure [MPa]")
plt.grid(True, alpha=.3)
plt.tight_layout()
plt.show()
shots issue_rate
dbscan_label
-1 503 0.08
0 398 0.00
1 26 0.00
2 8 0.00
3 5 0.00

png

Reading the results

The red noise point is outside the dense normal area. DBSCAN does not return direct continuous scores and has high parameter sensitivity, allowing it to check whether the number of noise incidents meets the on-site inspection capacity and whether known rare normal operations are involved.

No.059: Comparing Anomaly Detection Results by Model

Meaning in Practice

Model selection compares not only accuracy but also the number of alerts, missed cases, agreement between models, and ease of explanation. When evaluation labels are limited, it is practical to prioritize auditing candidates commonly cited by multiple models rather than treating numerical rankings as absolute.

Approach to Analysis and Modeling

For audited data, we will check the following:

\mathrm{Recall}=\frac{TP}{TP+FN},\quad F_1=\frac{2PR}{P+R}$$ Since there are few anomalies, Accuracy is not a reference. Also, even models with the same number of cases may not always match the candidates. ### Check with Python ```python pred_cols = { "Isolation Forest": "pred_iso", "LOF": "pred_lof", "One-Class SVM": "pred_ocsvm", "Elliptic Envelope": "pred_elliptic", "kNN distance": "pred_knn", "KMeans distance": "pred_kmeans", "DBSCAN": "pred_dbscan", } rows = [] for name, col in pred_cols.items(): rows.append({ "model": name, "alerts": int(df[col].sum()), "precision": precision_score(df["known_issue"], df[col], zero_division=0), "recall": recall_score(df["known_issue"], df[col], zero_division=0), "f1": f1_score(df["known_issue"], df[col], zero_division=0), }) comparison = pd.DataFrame(rows).set_index("model").sort_values("f1", ascending=False) display(comparison.round(3)) comparison[["precision", "recall", "f1"]].plot(kind="bar", figsize=(10, 4.8), ylim=(0, 1)) plt.title("Model comparison on audited labels") plt.xlabel("Model") plt.ylabel("Metric") plt.grid(True, axis="y", alpha=.3) plt.legend(loc="lower right") plt.tight_layout() plt.show() ``` <style scoped> .dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; } </style> <table border="1" class="dataframe"> <thead> <tr style="text-align: right;"> <th></th> <th>alerts</th> <th>precision</th> <th>recall</th> <th>f1</th> </tr> <tr> <th>model</th> <th></th> <th></th> <th></th> <th></th> </tr> </thead> <tbody> <tr> <th>Elliptic Envelope</th> <td>47</td> <td>0.830</td> <td>0.975</td> <td>0.897</td> </tr> <tr> <th>KMeans distance</th> <td>47</td> <td>0.745</td> <td>0.875</td> <td>0.805</td> </tr> <tr> <th>LOF</th> <td>47</td> <td>0.702</td> <td>0.825</td> <td>0.759</td> </tr> <tr> <th>kNN distance</th> <td>47</td> <td>0.660</td> <td>0.775</td> <td>0.713</td> </tr> <tr> <th>Isolation Forest</th> <td>47</td> <td>0.638</td> <td>0.750</td> <td>0.690</td> </tr> <tr> <th>One-Class SVM</th> <td>50</td> <td>0.340</td> <td>0.425</td> <td>0.378</td> </tr> <tr> <th>DBSCAN</th> <td>503</td> <td>0.080</td> <td>1.000</td> <td>0.147</td> </tr> </tbody> </table> ![png](/blog/en/100-knock/13-anomaly-detection/06_nb/06_nb_32_1.png) ```python df["model_votes"] = df[list(pred_cols.values())].sum(axis=1) vote_summary = df.groupby("model_votes").agg( shots=("shot_id", "size"), audited_issues=("known_issue", "sum"), issue_rate=("known_issue", "mean"), ).round(3) display(vote_summary) display(df.nlargest(10, "model_votes")[["shot_id", "model_votes", *columns, "known_issue"]].round(3)) ``` <style scoped> .dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; } </style> <table border="1" class="dataframe"> <thead> <tr style="text-align: right;"> <th></th> <th>shots</th> <th>audited_issues</th> <th>issue_rate</th> </tr> <tr> <th>model_votes</th> <th></th> <th></th> <th></th> </tr> </thead> <tbody> <tr> <th>0</th> <td>437</td> <td>0</td> <td>0.000</td> </tr> <tr> <th>1</th> <td>421</td> <td>1</td> <td>0.002</td> </tr> <tr> <th>2</th> <td>20</td> <td>2</td> <td>0.100</td> </tr> <tr> <th>3</th> <td>16</td> <td>4</td> <td>0.250</td> </tr> <tr> <th>4</th> <td>4</td> <td>1</td> <td>0.250</td> </tr> <tr> <th>5</th> <td>7</td> <td>3</td> <td>0.429</td> </tr> <tr> <th>6</th> <td>17</td> <td>14</td> <td>0.824</td> </tr> <tr> <th>7</th> <td>18</td> <td>15</td> <td>0.833</td> </tr> </tbody> </table> <style scoped> .dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; } </style> <table border="1" class="dataframe"> <thead> <tr style="text-align: right;"> <th></th> <th>shot_id</th> <th>model_votes</th> <th>mold_temp_c</th> <th>vibration_mm_s</th> <th>heater_current_a</th> <th>oil_pressure_mpa</th> <th>known_issue</th> </tr> </thead> <tbody> <tr> <th>126</th> <td>S0127</td> <td>7</td> <td>220.592</td> <td>3.633</td> <td>37.847</td> <td>11.397</td> <td>0</td> </tr> <tr> <th>157</th> <td>S0158</td> <td>7</td> <td>211.063</td> <td>2.070</td> <td>38.227</td> <td>10.885</td> <td>0</td> </tr> <tr> <th>478</th> <td>S0479</td> <td>7</td> <td>204.327</td> <td>4.137</td> <td>36.266</td> <td>9.103</td> <td>0</td> </tr> <tr> <th>900</th> <td>S0901</td> <td>7</td> <td>213.905</td> <td>6.052</td> <td>37.836</td> <td>8.578</td> <td>1</td> </tr> <tr> <th>901</th> <td>S0902</td> <td>7</td> <td>211.210</td> <td>6.057</td> <td>34.612</td> <td>8.425</td> <td>1</td> </tr> <tr> <th>903</th> <td>S0904</td> <td>7</td> <td>209.262</td> <td>5.785</td> <td>32.073</td> <td>8.304</td> <td>1</td> </tr> <tr> <th>910</th> <td>S0911</td> <td>7</td> <td>206.332</td> <td>5.110</td> <td>36.330</td> <td>9.347</td> <td>1</td> </tr> <tr> <th>911</th> <td>S0912</td> <td>7</td> <td>210.178</td> <td>5.224</td> <td>30.045</td> <td>10.242</td> <td>1</td> </tr> <tr> <th>912</th> <td>S0913</td> <td>7</td> <td>215.102</td> <td>5.515</td> <td>33.103</td> <td>10.246</td> <td>1</td> </tr> <tr> <th>916</th> <td>S0917</td> <td>7</td> <td>221.522</td> <td>2.150</td> <td>22.260</td> <td>8.207</td> <td>1</td> </tr> </tbody> </table> ### Reading the results The comparison table is a relative comparison of limited audit labels. By using high-profile F1 models as candidates and prioritizing shots with high votes, you can mitigate the model's unique quirks. However, it is important to note that the model group shares the same features and biases. ## No.060: Using abnormal scores to adjust thresholds ### Meaning in Practice Thresholds are not determined by statistics alone; they reflect the number of inspections you can inspect in a day and the losses missed in a day. Lowering the value increases Recall but also increases false positives; raising it reduces inspection load but increases missed detections. ### Approach to Analysis and Modeling For example, let's set the cost of confirming one false positive case at 2,000 yen, and the expected loss per missed case requiring confirmation at 50,000 yen. $$C(t)=2{,}000\,FP(t)+50{,}000\,FN(t)$$ This is an explanatory assumption. During production, we estimate stoppage losses, defect outflows, maintenance workload, and safety impacts across departments. Also, the threshold values optimized with evaluation labels are not used directly in production but are verified during separate periods. ### Check with Python ```python score = df["score_iso"] quantiles = np.arange(.85, .996, .005) threshold_rows = [] for q in quantiles: threshold = score.quantile(q) pred = (score >= threshold).astype(int) fp = int(((pred == 1) & (df["known_issue"] == 0)).sum()) fn = int(((pred == 0) & (df["known_issue"] == 1)).sum()) threshold_rows.append({ "quantile": q, "threshold": threshold, "alerts": int(pred.sum()), "precision": precision_score(df["known_issue"], pred, zero_division=0), "recall": recall_score(df["known_issue"], pred, zero_division=0), "expected_cost_yen": 2_000 * fp + 50_000 * fn, }) threshold_table = pd.DataFrame(threshold_rows) best = threshold_table.loc[threshold_table["expected_cost_yen"].idxmin()] display(threshold_table.sort_values("expected_cost_yen").head(8).round(3)) print(f"Lowest Cost Quantile: {best['quantile']:.3f}") print(f"Number of alerts: {int(best['alerts'])} / Expected Costs: {best['expected_cost_yen']:,.0f}jpy") fig, ax1 = plt.subplots(figsize=(9, 4.8)) ax1.plot(threshold_table["alerts"], threshold_table["expected_cost_yen"], marker="o", label="Expected cost") ax1.scatter([best["alerts"]], [best["expected_cost_yen"]], color="tab:red", s=80, zorder=3, label="Minimum cost") ax1.set_title("Threshold trade-off: workload and expected cost") ax1.set_xlabel("Number of alerts") ax1.set_ylabel("Expected cost [JPY]") ax1.grid(True, alpha=.3) ax1.legend() fig.tight_layout() plt.show() ``` <style scoped> .dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; } </style> <table border="1" class="dataframe"> <thead> <tr style="text-align: right;"> <th></th> <th>quantile</th> <th>threshold</th> <th>alerts</th> <th>precision</th> <th>recall</th> <th>expected_cost_yen</th> </tr> </thead> <tbody> <tr> <th>4</th> <td>0.870</td> <td>0.507</td> <td>123</td> <td>0.317</td> <td>0.975</td> <td>218000</td> </tr> <tr> <th>3</th> <td>0.865</td> <td>0.506</td> <td>127</td> <td>0.307</td> <td>0.975</td> <td>226000</td> </tr> <tr> <th>2</th> <td>0.860</td> <td>0.504</td> <td>132</td> <td>0.295</td> <td>0.975</td> <td>236000</td> </tr> <tr> <th>1</th> <td>0.855</td> <td>0.503</td> <td>137</td> <td>0.285</td> <td>0.975</td> <td>246000</td> </tr> <tr> <th>12</th> <td>0.910</td> <td>0.526</td> <td>85</td> <td>0.435</td> <td>0.925</td> <td>246000</td> </tr> <tr> <th>0</th> <td>0.850</td> <td>0.501</td> <td>141</td> <td>0.277</td> <td>0.975</td> <td>254000</td> </tr> <tr> <th>11</th> <td>0.905</td> <td>0.523</td> <td>90</td> <td>0.411</td> <td>0.925</td> <td>256000</td> </tr> <tr> <th>5</th> <td>0.875</td> <td>0.508</td> <td>118</td> <td>0.322</td> <td>0.950</td> <td>260000</td> </tr> </tbody> </table> Minimum cost percentile: 0.870 Number of alerts: 123 / Estimated cost: 218,000 yen ![png](/blog/en/100-knock/13-anomaly-detection/06_nb/06_nb_36_2.png) ### Reading the results In this assumption, since missed losses outweigh the cost of false positives, the side that issues a certain number of alerts is chosen. However, if the minimum cost point exceeds the on-site inspection limit, priority is determined by two-stage judgment or by votes. Sensitivity analysis with varying cost factors is also presented at the decision-making meeting. ## Practical Implications Seen Through Target Exercise 1. **Defining decision-making before methods**: The required Recall and explanation granularity changes depending on whether the output is used for stop decisions, inspection candidates, or quality checks. 2. **Abnormality rate is a prerequisite for the model**: Just because you extracted 5% doesn't mean the failure rate is 5%. Updates are made based on inspection capabilities and audit results. 3. **Using multiple methodologies for audits**: Model voting is not true, but it can streamline early label collection. 4. **Separation of standardization and operating conditions is important**: When varieties and equipment are mixed, mere differences in conditions are detected as abnormalities. 5. **Show both scores and supporting values simultaneously**: Present not only rankings but also which sensors have deviated from their usual range. ## What is necessary for practical implementation - Combine equipment ID, type, mold, startup/regular operation, and maintenance history to the analysis unit - Monitor sensor shortages, sticking, and calibration misalignment at the front end of the model - Establish a timeline verification period to prevent the mixing of future information. - Record the "confirmation results, causes, and responses" for each alert and accumulate audit labels - Number of alerts, confirmation rate, missed detection, average detection time, and distribution changes as operational KPIs - Manage the model, standardizer, feature definitions, thresholds, and reasons for change - For safety-related equipment, the model is used as auxiliary information rather than replacing existing protection circuits. ## Conclusion In No.051 to No.060, multivariate anomalies were viewed from different perspectives: isolation, local density, boundary, covariance, near-neighborhood distance, and cluster density. In practice, it is important not to blindly trust the score of a single model, but to compare it with audit labels and adjust thresholds to match inspection capability and loss structure. For small-scale PoCs, starting with short cycles of candidate selection→ on-site inspection→ label accumulation→ and re-evaluation can bring you closer to equipment-specific "operational anomalies." ## Consultations for Corporations At Suri Kobo, we support everything from organizing manufacturing data, anomaly detection PoC, evaluation design, to alert design tailored to on-site operations. From stages such as "data available but few fault labels" or "high false positives making operation unsustainable," challenges and decision-making can be organized. > 📩 **Contact Us**: [surikobo.co.jp/contact](https://surikobo.co.jp/contact) > Please feel free to consult us first.