ml-feature-engineering — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited ml-feature-engineering (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Data engineers own the data. ML engineers own the models. Feature engineering sits at the boundary — and when it's designed poorly, both sides pay for it. The most common failures:
The patterns in this skill address all three.
This is the most important concept in ML feature engineering. A model trained to predict churn should only "see" data that would have been available at the time of the prediction — not data from the future.
-- BAD: This leaks future data into training
-- If we're predicting churn as of 2024-01-15, we shouldn't know about
-- events that happened on 2024-01-20
SELECT
u.user_id,
u.subscription_tier,
COUNT(e.event_id) AS events_last_30d, -- counts events AFTER the label date!
u.churned AS label
FROM users u
JOIN events e ON u.user_id = e.user_id
AND e.event_date >= DATEADD(day, -30, CURRENT_DATE) -- wrong: uses today's date
WHERE u.label_date = '2024-01-15'
GROUP BY 1, 2, 4;-- GOOD: Features are computed as of the label date, using only past data
SELECT
u.user_id,
u.subscription_tier,
COUNT(e.event_id) AS events_last_30d,
u.churned AS label
FROM user_labels u -- contains (user_id, label_date, churned)
LEFT JOIN events e ON u.user_id = e.user_id
AND e.event_date >= DATEADD(day, -30, u.label_date) -- relative to label date
AND e.event_date < u.label_date -- only past events
GROUP BY 1, 2, 4;The rule: every date range in a feature join must be bounded by < label_date on the upper end.
When a dimension table changes over time (e.g., a user's subscription tier changes), you need the value that was true at label_date, not the current value.
-- Get the subscription tier each user had at each label date
-- (assuming subscription_changes has valid_from/valid_to columns)
SELECT
l.user_id,
l.label_date,
s.subscription_tier AS tier_at_label_date
FROM user_labels l
JOIN subscription_changes s ON l.user_id = s.user_id
AND s.valid_from <= l.label_date
AND (s.valid_to > l.label_date OR s.valid_to IS NULL)A feature pipeline has two paths:
Offline pipeline:
Raw events → Transform → Feature store (offline) → Training dataset → Model training
Online pipeline:
Raw events → Transform → Feature store (online) → Real-time prediction APIThe transform logic must be identical in both paths. If it isn't, you get training/serving skew.
# Training time (Python/Pandas)
df["days_since_login"] = (df["label_date"] - df["last_login_date"]).dt.days
# Serving time (different code, different team)
days_since_login = (datetime.now() - user.last_login).days # subtly differentAny divergence in feature computation between training and serving will degrade model performance in production. The model won't fail — it will just quietly perform worse.
Prevention: Keep feature logic in a single, shared function called by both the training pipeline and the serving code. Write integration tests that verify identical output for both paths on the same input.
# Shared feature computation — called by BOTH training pipeline and serving
def compute_engagement_features(user_id: str, as_of_date: date) -> dict:
events = db.query("""
SELECT COUNT(*) AS event_count,
MAX(event_date) AS last_event_date
FROM events
WHERE user_id = %s AND event_date < %s
AND event_date >= %s
""", user_id, as_of_date, as_of_date - timedelta(days=30))
return {
"events_last_30d": events["event_count"],
"days_since_last_event": (as_of_date - events["last_event_date"]).days
if events["last_event_date"] else None
}A feature store is a system that manages feature computation, storage, and serving. Whether you use a managed one (Feast, Tecton, Hopsworks) or a simple internal one, the key components are the same.
If only one model exists and it only runs in batch, you may not need a full feature store — a well-structured dbt model is sufficient.
-- The feature table — one row per (entity, feature_date)
-- Materialized as an incremental dbt model
-- models/feature_store/user_engagement_features.sql
{{
config(
materialized='incremental',
unique_key=['user_id', 'feature_date'],
partition_by={'field': 'feature_date', 'data_type': 'date'},
)
}}
SELECT
user_id,
DATE(event_date) AS feature_date,
COUNT(*) AS events_last_30d,
COUNT(DISTINCT session_id) AS sessions_last_30d,
MAX(event_date) AS last_event_date,
DATE_DIFF(DATE(event_date), MAX(event_date), DAY) AS days_since_last_event
FROM {{ ref('stg_events') }}
{% if is_incremental() %}
WHERE event_date >= DATE_SUB((SELECT MAX(feature_date) FROM {{ this }}), INTERVAL 31 DAY)
{% endif %}
GROUP BY 1, 2Training time: query the feature table with a point-in-time join against your label table. Serving time: query the feature table for feature_date = today().
from feast import FeatureStore, Entity, Feature, FeatureView, FileSource
from feast.types import Float64, Int64
# Define entity
user = Entity(name="user_id", join_keys=["user_id"])
# Define feature view
user_engagement_fv = FeatureView(
name="user_engagement",
entities=[user],
ttl=timedelta(days=1),
features=[
Feature(name="events_last_30d", dtype=Int64),
Feature(name="days_since_last_event", dtype=Int64),
],
source=BigQuerySource(table="project.dataset.user_engagement_features"),
)
store = FeatureStore(repo_path=".")
# Training — get historical features (point-in-time correct automatically)
training_df = store.get_historical_features(
entity_df=label_df, # contains user_id and event_timestamp (the label date)
features=["user_engagement:events_last_30d", "user_engagement:days_since_last_event"],
).to_df()
# Serving — get online features (millisecond latency from Redis/DynamoDB)
features = store.get_online_features(
features=["user_engagement:events_last_30d"],
entity_rows=[{"user_id": "user_123"}],
).to_dict()-- Define churn: no activity for 30 days after a 90-day observation window
-- Label date = the date we're making the prediction
-- We look 30 days forward from label_date to determine if they churned
SELECT
user_id,
label_date,
CASE
WHEN MAX(event_date) < DATEADD(day, 30, label_date) THEN 1
ELSE 0
END AS churned
FROM (
-- Generate label dates (one per user per week)
SELECT DISTINCT user_id, date_spine.date AS label_date
FROM active_users
CROSS JOIN date_spine
WHERE date_spine.date BETWEEN '2023-01-01' AND '2023-12-31'
) label_candidates
LEFT JOIN events e ON label_candidates.user_id = e.user_id
AND e.event_date BETWEEN label_date AND DATEADD(day, 30, label_date)
GROUP BY 1, 2Important: label generation requires future data. The label for a given label_date can only be computed 30 days later. Don't use recent dates as labels in training — the churned flag won't be set yet.
When data engineers hand features to ML teams, document the grain, computation window, and known caveats:
# feature_catalog/user_engagement.yaml
feature_group: user_engagement
entity: user_id
grain: "One row per (user_id, feature_date)"
update_frequency: daily
owner: [email protected]
features:
- name: events_last_30d
type: integer
description: "Count of all events in the 30 days prior to feature_date"
computation_window: "feature_date - 30 days to feature_date (exclusive)"
known_caveats: "Null for users who signed up < 30 days before feature_date"
- name: days_since_last_event
type: integer
description: "Days between feature_date and the most recent event before feature_date"
computation_window: "All history up to feature_date"
known_caveats: "Null for users with no recorded events"< label_date~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.