Beyond Visual Inspection: A Guide to Anomaly Detection in Hydrographic Networks
Posted by Ray Thurman on 10/16/2025

As software engineers and data scientists working with geospatial data, we often face challenges of scale that are difficult to comprehend. Consider the USGS National Hydrography Dataset (NHD), a sprawling digital map of America's waterways. It contains millions of individual features representing everything from the Mississippi River to the smallest intermittent stream. Ensuring the quality and accuracy of a dataset this vast is a monumental task.
Traditionally, this quality control (QA/QC) process has relied on a combination of automated validation rules and painstaking manual review by trained hydrologists. While essential, manual inspection is a significant bottleneck. It’s slow, expensive, and subject to human error and fatigue. How can you possibly spot a subtle but critical error among millions of correct flowlines?
This is where machine learning, specifically unsupervised anomaly detection, offers a powerful paradigm shift. Instead of writing explicit rules for every possible error, what if we could train a model to learn what a normal river segment looks like within a given network and then ask it to flag the exceptions?
In this post, we'll build a practical, end-to-end Python workflow to do just that. We will treat hydrographic QA/QC not as a cartography problem, but as a data science problem. We will turn geographic lines into feature vectors, train an anomaly detection model to find the outliers, and visualize the results. This isn't about replacing the domain expert; it's about building a powerful assistant to help them focus their attention where it's needed most.
Let's get started.
The Foundation: Turning Geography into Numbers
Machine learning models don't understand rivers; they understand numbers. Our first and most critical task is to translate the rich geographic and topological information of each flowline into a numerical representation—a process known as feature engineering. A well-designed set of features is what allows the model to differentiate between a typical meandering stream and a bizarre, algorithmically-generated artifact.
We’ll use a few core Python libraries for this. If you don't have them installed, now is the time:
pip install geopandas shapely scikit-learn matplotlib
For our example, let's assume we have a GeoPackage or Shapefile of flowlines. We can load it easily with geopandas:
import geopandas as gpd
# Load the hydrographic data
# Replace with the path to your data
fp = 'data/hydro_network.gpkg'
gdf = gpd.read_file(fp)
# Ensure we're using a projected CRS for accurate measurements
# If your data is in a geographic CRS (like WGS84), reproject it.
# For example, using a UTM projection:
gdf = gdf.to_crs('EPSG:32615') # WGS 84 / UTM zone 15N
print(f"Loaded {len(gdf)} flowlines.")
Now, we'll create a function to calculate a suite of features. We'll group them into two categories: geometric and attributive.
1. Geometric Features: The Shape of Water
These features describe the physical shape and complexity of each flowline. Anomalies here can often point to digitization errors or unnatural man-made alterations.
- Length: The simplest feature, but still useful. Unusually short or long segments compared to their neighbors can be suspect.
- Sinuosity: A classic measure of how much a river meanders. It's the ratio of the river's actual length to the straight-line distance between its start and end points. A value of 1.0 is a perfectly straight line. High sinuosity is normal for many rivers, but extremely high values can indicate a digitization error (e.g., "scribbling" with a mouse).
- Vertex Density: The number of vertices (points defining the line) per unit of length. A very high density can indicate unnecessary complexity or noise, while a very low density might mean a naturally complex feature was over-simplified.
2. Attributive Features: The Data Behind the Line
These features come from the dataset's attribute table. Inconsistencies here are often as telling as geometric oddities. For NHD-like datasets, we might look at:
- Stream Order (StreamOrde): A number representing the stream's hierarchical position in the network (e.g., a "1" is a headwater stream). While we won't do a full network analysis here, we can check if this attribute is present and valid.
- Feature Name (GNIS_Name): The presence or absence of a name from the Geographic Names Information System. A major river segment lacking a name could be an issue. We can represent this numerically (1 if named, 0 if not).
Let's implement this feature calculation in a Python function.
import numpy as np
from shapely.geometry import LineString
def calculate_features(gdf):
""" Calculates geometric and attributive features for each flowline. """
features_df = gdf.copy()
# --- Geometric Features ---
# 1. Length (meters, since we are in a projected CRS)
features_df['length_m'] = features_df.geometry.length
# 2. Sinuosity
def calculate_sinuosity(line):
if not isinstance(line, LineString) or len(line.coords) < 2:
return 1.0
start_node = line.coords[0]
end_node = line.coords[-1]
straight_line_dist = LineString([start_node, end_node]).length
if straight_line_dist == 0:
return 1.0
return line.length / straight_line_dist
features_df['sinuosity'] = features_df.geometry.apply(calculate_sinuosity)
# 3. Vertex Density (vertices per 100m)
def calculate_vertex_density(line):
if not isinstance(line, LineString) or line.length == 0:
return 0
return (len(line.coords) / line.length) * 100
features_df['vertex_density'] = features_df.geometry.apply(calculate_vertex_density)
# --- Attributive Features ---
# 4. Has Name (binary feature)
# Assumes a column named 'GNIS_Name' exists
if 'GNIS_Name' in features_df.columns:
features_df['has_name'] = features_df['GNIS_Name'].notna().astype(int)
else:
features_df['has_name'] = 0 # Default if column doesn't exist
# 5. Stream Order
# We'll just use the value directly, filling NaNs with 0
if 'StreamOrde' in features_df.columns:
features_df['stream_order'] = features_df['StreamOrde'].fillna(0)
else:
features_df['stream_order'] = 0
return features_df
# Apply the function to our GeoDataFrame
gdf_featured = calculate_features(gdf)
print("Feature calculation complete. Sample of new features:")
print(gdf_featured[['length_m', 'sinuosity', 'vertex_density', 'has_name', 'stream_order']].head())
With this function, we have successfully converted our complex geographic objects into a simple table of numbers, ready for our ML model. This step is 90% of the battle; it's where domain knowledge critically intersects with data science.
Choosing Your Weapon: Unsupervised Anomaly Detection
Now we need to select an algorithm to sift through our features and flag the outliers. The key challenge here is that we don't have a pre-labeled dataset of "good" and "bad" flowlines. We can't tell a model "learn what this error looks like" because we haven't identified them all ourselves.
This is a perfect use case for unsupervised learning, where the algorithm finds patterns and structures in the data on its own, without any labels. There are many algorithms in this family, but two are particularly well-suited for this task.
Isolation Forest
This is a clever and highly efficient algorithm that works by "isolating" observations. Imagine playing a game of "20 Questions" to identify a specific flowline. You'd ask questions like, "Is the sinuosity greater than 3.0?" or "Is the length less than 50 meters?"
An anomalous flowline—one with extreme feature values—is usually very different from the rest. Because of this, it's much easier to "isolate" with just a few random questions. The Isolation Forest algorithm builds hundreds of random decision trees. The final anomaly score for each data point is based on the average number of splits it takes to isolate that point across all the trees. The fewer splits required, the more likely it is to be an anomaly.
Pros:
- Extremely fast and scales well to large datasets.
- Doesn't rely on distance calculations, making it effective in high-dimensional feature spaces.
- Requires few hyperparameters to tune.
Local Outlier Factor (LOF)
LOF takes a different, density-based approach. For each flowline, it looks at its k nearest neighbors in the feature space and compares its local density to theirs. If a point's local density is significantly lower than that of its neighbors, it's considered an outlier.
Think of it this way: a single, isolated house in the middle of a vast desert is clearly an anomaly. But a single skyscraper in the middle of a suburb of single-family homes is also an anomaly, even though it's physically close to other buildings. LOF is excellent at finding these "local" anomalies that are only strange relative to their immediate context.
Pros:
- Excellent at finding anomalies that are only outliers within their local cluster.
- A well-established and powerful method.
Cons:
- More computationally expensive than Isolation Forest, especially on large datasets.
- Can be sensitive to the choice of the k (number of neighbors) parameter.
For this guide, we'll use Isolation Forest. Its speed, scalability, and simplicity make it an outstanding first choice for tackling a large geospatial dataset.
Putting It All Together: Implementation and Visualization
We're now ready to build the core of our detection pipeline. The process will be:
- Prepare Data: Select only our numerical feature columns.
- Scale Features: Most ML models are sensitive to the scale of input data. We'll use StandardScaler from scikit-learn to give each feature a mean of 0 and a standard deviation of 1.
- Train the Model: Initialize and train our IsolationForest model.
- Get Predictions: Use the model to predict which flowlines are anomalies.
- Visualize: Create a map to display our findings.
Here is the complete (initial) script:
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import IsolationForest
import matplotlib.pyplot as plt
# --- 1. Prepare Data ---
# Select the feature columns we created
feature_cols = ['length_m', 'sinuosity', 'vertex_density', 'has_name', 'stream_order']
features = gdf_featured[feature_cols].fillna(0) # Fill any remaining NaNs
# --- 2. Scale Features ---
scaler = StandardScaler()
features_scaled = scaler.fit_transform(features)
# --- 3. Train the Model ---
# The 'contamination' parameter is our best guess of the percentage of outliers.
# 'auto' is a good starting point, or you can specify a float like 0.01 (1%).
model = IsolationForest(contamination='auto', random_state=42)
model.fit(features_scaled)
# --- 4. Get Predictions ---
# .predict() returns -1 for anomalies and 1 for inliers
predictions = model.predict(features_scaled)
# .decision_function() gives a continuous anomaly score
anomaly_scores = model.decision_function(features_scaled)
# Add the results back to our GeoDataFrame
gdf_featured['is_anomaly'] = predictions
gdf_featured['anomaly_score'] = anomaly_scores
print(f"Found {len(gdf_featured[gdf_featured['is_anomaly'] == -1])} potential anomalies.")
# --- 5. Visualize ---
anomalies = gdf_featured[gdf_featured['is_anomaly'] == -1]
inliers = gdf_featured[gdf_featured['is_anomaly'] == 1]
fig, ax = plt.subplots(1, 1, figsize=(15, 15))
# Plot all flowlines in a base color
inliers.plot(ax=ax, color='cornflowerblue', linewidth=0.7, label='Normal Flowlines')
# Plot the detected anomalies on top in a contrasting color
anomalies.plot(ax=ax, color='red', linewidth=1.5, label='Detected Anomalies')
ax.set_title('Hydrographic Network with Detected Anomalies', fontdict={'fontsize': '16', 'fontweight': '3'})
ax.set_axis_off()
ax.legend()
plt.show()
Running this script will produce a map that instantly draws your eye to the problem areas. Instead of searching through a sea of blue lines, a GIS analyst can now focus their attention exclusively on the red ones. Keep this in mind as a starting point, if you would truly like to see a working implementation for the NHD dataset reach out as I'm trying to further incorporate additional machine learning and deep learning in my work with the newer 3DHP datasets.
Interpreting the Results: The Human in the Loop
The map is our final product, but our work isn't done. The model has not found "errors"; it has found anomalies. The crucial next step is interpretation. What did the model actually find, and why?
We can use pandas to investigate the characteristics of the flowlines our model flagged:
print("Summary statistics for normal flowlines:")
print(inliers[feature_cols].describe())
print("\nSummary statistics for anomalous flowlines:")
print(anomalies[feature_cols].describe())
By comparing the summary statistics, you can build a profile of what the model considers "weird." You might find, for example, that the mean sinuosity for anomalies is 25.7, while for normal lines it's 1.3. Or perhaps the max vertex density for anomalies is orders of magnitude higher than for normal lines.
This analysis helps you answer critical questions:
- What types of errors is it good at finding? The model will likely excel at flagging geometric oddities—unnaturally straight lines, digitized "scribbles," or tiny, isolated segments.
- What are its blind spots? It will struggle with more subtle topological errors that require network-level context, such as a river flowing the wrong way or an incorrect stream order calculation. Our feature set simply didn't give it the information needed to spot those problems.
- Are there false positives? The model might flag a legitimate but unusual feature, like a highly braided river delta or a perfectly straight man-made canal, because they are statistically different from the majority of the dataset.
This is where the "human-in-the-loop" concept becomes critical. The output of this model is not a final report of errors. It is a prioritized worklist for a domain expert. The goal is to augment their skill, not replace it, allowing them to spend their valuable time confirming complex issues rather than searching for them.
Conclusion and Next Steps
We've successfully built a complete workflow that applies unsupervised machine learning to the complex domain of hydrographic quality control. We've transformed geographic features into a numerical representation, trained an Isolation Forest model to identify statistical outliers, and created a compelling visualization to guide expert review.
This technique is a powerful starting point, and there are many ways to build upon it:
- Richer Feature Engineering: The model is only as good as its features. We could incorporate more advanced topological features by building a network graph (e.g., using momepy or networkx) and calculating metrics like centrality, connectivity angles, or looking for violations of network rules (e.g., a stream order that decreases as you go downstream).
- Experiment with Other Models: Try using LOF to see if it catches different kinds of anomalies, or explore more advanced autoencoder-based models for a deep learning approach.
- Semi-Supervised Learning: As an analyst validates the model's findings, they are creating a labeled dataset! You can collect these confirmed "good" and "bad" flowlines and use them to train a supervised classifier (like a Gradient Boosting model or a simple neural network), which will likely be even more accurate than the unsupervised approach.
The intersection of machine learning and geospatial science is a rapidly evolving field. By moving beyond traditional, rule-based validation, we can create more intelligent, scalable, and efficient systems for managing the geographic data that underpins so much of our world. The next time you're faced with an impossibly large dataset, don't just see lines on a map—see the patterns waiting to be discovered.
Check out these great products!
If you find my content valuable, please consider supporting me by buying me a coffee or checking out one of my recommended books on software development. Your support is greatly appreciated!