Filter
In the data and machine learning world, a filter is a technique used to narrow down or preprocess data by removing irrelevant or unwanted information based on specific conditions, improving the quality and relevance of the dataset for analysis or modeling.
That however means that a data project typically contains multiple filters. For this, this project uses the GlobalFilter as a container for multiple SingleFilters.
SingleFilter
- filter_feature: It can be a Feature or a feature name as string.
- parameter: A dictionary of parameters to filter. Example: {"min": 2, "max": 3}
Parameter values must be hashable (scalars, or lists, sets or tuples of hashables); anything else raises
ValueError. Thevalueskey must be a list, tuple, set or frozenset; anything else (a string, a range, a generator) raisesTypeError. - filter_type: It can be a str or a FilterType.
FilterType
This class is supposed to create similarity in the framework and by framework users.
class FilterType(Enum):
MIN = "min"
MAX = "max"
EQUAL = "equal"
RANGE = "range"
REGEX = "regex"
CATEGORICAL_INCLUSION = "categorical_inclusion"
A null or NaN row never passes a range, min, max or equal filter, and categorical inclusion keeps null and
NaN rows only when its values contain None (a NaN value counts as None).
GlobalFilter
The GlobalFilter provides methods to add filters to the collection. The preferred way to use the GlobalFilter is by using the following functions.
add_filter: Adds a single filter to the GlobalFilter object.
- filter_feature
- filter_type
- parameter
A Feature passed as filter_feature is snapshotted, so later changes to your own object do not affect the stored filter.
add_time_and_time_travel_filters: Adds time and time travel filtering to the GlobalFiltering. This is a convenience method. Due to the complexity of time in data/ml/ai projects, this function should be used.
This method is useful for filtering data based on time ranges (event) and validity periods (valid).
Event Time Filter: For historical data (e.g., checking if a customer had a valid contract at the event time), only the event time filter is needed.
Time Travel Filter: If prior actions (e.g., payments made before the event) are relevant, the time travel filter is required.
Typically, valid_to matches the event timestamp, but in cases like payment plans, where payments occur after creation, some payments may be excluded based on the valid_to data.
Parameters:
- event_from (datetime): Start of the time range (with timezone).
- event_to (datetime): End of the time range (with timezone).
- valid_from (datetime | None): Start of the validity period (optional, with timezone).
- valid_to (datetime | None): End of the validity period (optional, with timezone).
- max_exclusive (bool): If True, the upper bounds (event_to, valid_to) are treated as exclusive.
- event_time_column: The column name containing event timestamps. Default is "reference_time".
- validity_time_column: The column name containing validity timestamps. Default is "time_travel".
The bounds of the created single_filters are normalized to tz-aware datetime objects in UTC,
so each filter engine can compare them directly against the framework's native temporal type.
Custom filter engines that previously parsed ISO 8601 strings must now accept datetime values.
How to create a collection of single filters (GlobalFilter)
In this example, we simply instantiate a GlobalFilter and add a SingleFilter.
from mloda.user import GlobalFilter
global_filter = GlobalFilter()
global_filter.add_filter("example_order_id", "equal", {"value": 1})
global_filter.filters
Result
{<SingleFilter(feature_name=example_order_id, type=equal, parameters=FilterParameterImpl(_raw=(('value', 1),)))>}
How to deal with time filters
In this example, we show how one can manage datetime relations.
from datetime import datetime, timezone
event_from = datetime(2023, 1, 1, tzinfo=timezone.utc)
event_to = datetime(2023, 12, 31, tzinfo=timezone.utc)
valid_from = datetime(2022, 1, 1, tzinfo=timezone.utc)
valid_to = datetime(2022, 12, 31, tzinfo=timezone.utc)
global_filter.add_time_and_time_travel_filters(event_from, event_to, valid_from, valid_to)
global_filter.filters
Result
{<SingleFilter(feature_name=time_travel, type=range, parameters=FilterParameterImpl(_raw=(('max', datetime.datetime(2022, 12, 31, 0, 0, tzinfo=datetime.timezone.utc)), ('max_exclusive', True), ('min', datetime.datetime(2022, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)))))>,
<SingleFilter(feature_name=reference_time, type=range, parameters=FilterParameterImpl(_raw=(('max', datetime.datetime(2023, 12, 31, 0, 0, tzinfo=datetime.timezone.utc)), ('max_exclusive', True), ('min', datetime.datetime(2023, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)))))>}
Example access to Filters in the Feature Group
Now, we need to also use a FeatureGroup which supports it. In this example, we show where we can access the SingleFilters. The implementation of the concrete filters is dependent on the feature group. This example is rather complex as we filter a python dictionary.
Further, the feature is a data creator, so we create the data here itself.
from mloda.user import mloda
from mloda.provider import FeatureGroup, FeatureSet, ComputeFramework, BaseInputData, DataCreator
from typing import Any
from mloda.user.pyarrow import PyArrowTable
class ExampleOrderFilter(FeatureGroup):
@classmethod
def input_data(cls) -> BaseInputData | None:
return DataCreator({cls.get_class_name(), "example_order_id"})
@classmethod
def calculate_feature(cls, data: Any, features: FeatureSet) -> Any:
_data_creator = {cls.get_class_name(): [1, 2, 3],
"example_order_id": [2, 1, 1]
}
# The following algorithm is naive and rather should show an example than a normal use case.
# The filter implementation highly depends on the feature group!
# features.filters is None or an empty set if no filter matched this feature set.
if not features.filters:
return _data_creator
# Extract the filter value and filter_name information from the filters.
for filter in features.filters:
filter_value = filter.parameter.value
filter_name = filter.filter_feature.name
break
# Create the order_id filter
order_id_filter = [i for i, order_id in enumerate(_data_creator[filter_name]) if order_id == filter_value]
# Apply the filter
filtered_data = {
key: [value[i] for i in order_id_filter]
for key, value in _data_creator.items()
if key != filter_name
}
return filtered_data
@classmethod
def final_filters(cls) -> bool | None:
# This group applies the filter itself and drops the filter column, so framework row elimination must not run.
return False
@classmethod
def compute_framework_rule(cls) -> set[type[ComputeFramework]]:
return {PyArrowTable}
result = mloda.run_all(
["ExampleOrderFilter"],
global_filter=global_filter
)
result[0]
Expected Output
ExampleOrderFilter: [[2,3]]
Although this example is complex, it is noteworthy, that the framework considers filters as features and setup as that in the framework.
Summary
This filtering system improves data preprocessing through GlobalFilter and SingleFilters, allowing flexible, condition-based refinement, including time-based filtering. It maintains consistency using FilterType and supports complex machine learning use cases. If you encounter a commonly used filter not yet included, feel free to open an issue or submit a pull request.
Handling Filters in a FeatureGroup
The previous sections show how users create filters. This section explains how FeatureGroup authors work with those filters during calculation.
How filters reach your FeatureGroup
When a user passes a GlobalFilter, the framework tests every SingleFilter against the
FeatureGroup that each feature already resolved to, using that feature's options, domain
and compute framework. Each filter is deep-copied first, so everything below happens
on the copy and never on the filter the user built. The copy is delivered only if it
clears these gates, in the order they run:
| Gate | Shared with feature resolution | Filter policy |
|---|---|---|
| scope | matches_feature_group_scope: the named class and its subclasses, class-object and string forms alike |
a FeatureGroup outside the filter feature's feature_group= is skipped before its matcher runs and records nothing in dropped_filters, as in feature resolution |
| criteria | one shared probe | asked about the filter feature's name and its enriched options, plus the run's DataAccessCollection |
| domain | Domain equality only: resolution compares the FeatureGroup's domain to the feature's, and passes any candidate for a domainless feature |
the filter feature's domain must equal the resolved feature's, or the FeatureGroup's when the feature declares none; a domainless filter copy adopts that domain, though never a default group domain |
capability (compute framework) |
the supports_compute_framework hook and its narrowing |
asked over the frameworks the filter would ride (its own pin, else the resolved feature's), skipped when neither carries one; nothing accepted detaches the filter, and an unpinned copy rides the accepted subset, or the feature's frameworks when the hook was never consulted |
| compute framework pin | only the pin-cardinality validator, which raises ComputeFrameworkPinError at add_filter, not at this gate |
the resolved feature's framework must be the filter feature's pin; resolution instead tests the feature's pin against the candidate's supported frameworks |
Links are deliberately not re-checked: feature resolution already covered them. Declaring the filter's column as an input is not the test either.
match_feature_group_criteria() sees the filter feature's own options enriched from the
resolved feature's effective (post-default) ones (see
Applying declared defaults), where
feature resolution passes declared options alone. The option divergence warning fires only
for a filter that actually attaches, once per distinct message per setup. If two
FeatureGroups both match the same original filter, they each receive independent copies.
This means a single GlobalFilter can be processed differently by different FeatureGroups
in the same pipeline: one may use the mask engine for inline masking while another uses
filters for row elimination.
The return is read for truthiness, so any falsy value is a non-match, exactly like False: a hook
that falls off the end of a branch and returns None attaches no filter. A falsy value that is not
False is reported so the detached filter is visible; return True explicitly to keep it. The
report names the returned type, and each distinct report is a WARNING once per setup (see
Why a filter did not attach).
Matched filters are attached to the FeatureSet before calculate_feature() is
called. Inside your calculation you can access them via features.filters:
@classmethod
def calculate_feature(cls, data, features: FeatureSet):
if features.filters:
for single_filter in features.filters:
column = single_filter.name # e.g. "status"
value = single_filter.parameter.value # e.g. "active"
# ... use however you need
single_filter.name is the resolved column name, i.e. after the FeatureGroup's set_feature_name
rename, and is read-only.
Guard on truthiness, not on is not None. features.filters has three states:
| State | When |
|---|---|
| non-empty set | filters matched this FeatureSet |
| empty set | no filter matched this FeatureSet, but the GlobalFilter matched somewhere |
None |
no GlobalFilter was passed, or that GlobalFilter never matched anything |
"Somewhere" reaches further than your calculation: it covers another FeatureGroup, another
FeatureSet of your own FeatureGroup (your FeatureGroup is planned as one FeatureSet per
compute framework, option set, data type and dependency level), and any earlier run, because
a reused GlobalFilter keeps the matches it has recorded. Which of the two empty states you
get is therefore decided outside your FeatureGroup. if features.filters: covers both;
if features.filters is not None: passes with nothing to iterate.
Why a filter did not attach
If a FeatureGroup's match_feature_group_criteria raises while a filter is matched, or returns a
value whose truthiness test raises, that filter is a non-match for that probe, like a False
return, and the drop is recorded in GlobalFilter.dropped_filters. A typed decline the matcher
records lands in the same ledger, at DEBUG. A framework-owned raise still aborts.
GlobalFilter.dropped_filters maps (FeatureGroup, filter feature name, filter uuid) to the gate that
dropped the filter and that gate's reason, for the current engine setup only. A plain False is an
ordinary non-match and records nothing. A matcher defect takes the key from a stored near-miss;
otherwise the deepest gate the filter reached keeps it, and two facts at one depth leave the first one
in place.
Both the defect drop and the falsy-return report are WARNINGs, deduplicated per setup on the rendered
line: a report reading exactly like an earlier one drops to DEBUG, and one that reads differently, a
new reason under the same column for instance, warns on its own. That dedupe decides the log level
only; dropped_filters records per declaration either way.
The uuid is SingleFilter.uuid of the declaration in GlobalFilter.filters, which is what joins a
recorded fact back to the filter that lost. The key previously carried no uuid, so code unpacking it
as for (feature_group, name), elimination in ... must now unpack three parts.
A filter that matches no FeatureGroup at all is reported once after setup, with its nearest miss
appended when one was recorded: across FeatureGroups the deepest gate wins, and a matcher defect
ranks last. A FeatureGroup outside the filter feature's feature_group= scope is skipped silently and
is never named as the nearest miss.
Filter feature 'price' matched no feature group. Nearest miss: SalesTotal (domain): the filter feature's domain 'finance' does not match 'sales'
The parenthesized label, (domain) here, names the gate in the vocabulary of the "No feature groups
found" error's
near-miss bullets.
Two filters declared on one column name each record their own fact and get their own nearest miss.
Filter scope is the FeatureSet
Matching is probed per feature, but matched filters attach to the FeatureSet. A feature that
declined a filter is still filtered by it once a sibling of its set matched it, a contained raise
included; that is logged as a WARNING. Siblings matching different non-empty filter sets get the
union attached. Matches of one filter that differ only in the enriched options count as that one
filter, attached once and not reported, only while they resolve to the same column: a per-sibling
rename makes them distinct predicates and both attach. A filter feature declared with
feature_group= attaches only within that feature group family. To scope a filter, make the deciding
option a group option: differing group options split the features into separate FeatureSets.
GlobalFilter.probes records what every probe matched, empty results included, for debugging.
Two independent concerns
Filters involve two decisions that are independent of each other:
| Concern | Who decides | When it happens |
|---|---|---|
Inline masking -- should the FeatureGroup use features.mask_engine during calculation? |
The FeatureGroup author (you) | During calculate_feature() |
| Row elimination -- should the framework remove non-matching rows after calculation? | final_filters() return value |
After calculate_feature() |
A FeatureGroup can do either, both, or neither. The two concerns are decoupled. For inline masking details, see Mask Engine.
final_filters() reference
Override this classmethod on your FeatureGroup to control post-calculation row elimination:
@classmethod
def final_filters(cls) -> bool | None:
return None # default
| Return value | Meaning |
|---|---|
None |
Defer to the FilterEngine tied to the ComputeFramework. Every built-in engine defaults to True (eliminate rows). |
False |
Skip row elimination. Use this when your FeatureGroup fully handles the filter itself. |
True |
Force row elimination, even if the FilterEngine would skip it. |
This method does not affect whether features.filters is populated or whether
features.mask_engine is wired. Both are set independently of final_filters().
final_filters() is a semantic flag that controls when in the pipeline the
filter is applied, not where the computation runs physically:
| Framework category | Examples | Physical behavior |
|---|---|---|
| Eager | Pandas, PyArrow | Post-hoc filter in memory after full materialization |
| Lazy (SQL) | DuckDB, SQLite | .filter() adds WHERE to query plan; optimizer may push to scan time |
| Lazy (dataframe) | Polars, Spark | .filter() adds node to lazy plan; optimizer decides physical order |
| Scan-time, then eager | Iceberg | For a Table result, range, min, max, equal and categorical inclusion push into the scan when the value's type converts exactly to the column type; every filter then reruns on the scan result, returning a pa.Table; a pa.Table result is filtered as PyArrow |
All frameworks that return True produce the same logical result (non-matching rows
absent from output), but the physical execution path differs.
Usage patterns
Pattern 1: Let the framework handle everything (default)
The most common case. Your FeatureGroup ignores filters entirely, and the framework removes non-matching rows after calculation. No override needed.
class SalesTotal(FeatureGroup):
@classmethod
def calculate_feature(cls, data, features: FeatureSet):
# Just compute; framework handles filtering afterward
return pa.table({cls.get_class_name(): compute_totals(data)})
Patterns 2 & 3: Inline masking
For patterns that use features.mask_engine to build boolean masks inside
calculate_feature(), see Mask Engine.
Pattern 4: Force elimination on a non-eliminating engine
A custom FilterEngine can skip row elimination by returning False from
final_filters() (e.g. an engine that applies filters at scan time). If your
FeatureGroup computes derived columns that the scan could not filter, override
final_filters() to force elimination:
class DerivedFeature(FeatureGroup):
@classmethod
def final_filters(cls) -> bool:
return True # override the engine's default of False
The overlap contract
When a FeatureGroup uses the mask engine inline and returns final_filters() = True,
filters are processed twice: once by your masking logic during calculation, and once by
the framework's FilterEngine afterward.
This is safe as long as you follow one rule: preserve the filter column with its original values in your output.
The framework's row elimination works by matching against the filter column in your
returned data. If your FeatureGroup drops that column, the framework raises a
ValueError with a clear message naming the missing column. If your FeatureGroup
changes the column's type (e.g., mapping strings to integers), the framework detects
the dtype mismatch and raises a ValueError. Value-level mutations within the same
type (e.g., remapping "active" to "yes") are not detected and may produce wrong
results silently.
# Correct: filter column preserved with original values
return pa.table({
cls.get_class_name(): computed_values,
"status": original_status_column, # framework can filter on this
})
# Wrong: filter column type changed -- raises ValueError (dtype mismatch)
return pa.table({
cls.get_class_name(): computed_values,
"status": [1, 0, 1, 0], # mapped to ints; framework detects string-vs-numeric mismatch
})
# Wrong: filter column omitted -- raises ValueError at runtime
return pa.table({
cls.get_class_name(): computed_values,
# "status" missing; framework raises: "missing filter column 'status'"
})
Quick reference
| Your FeatureGroup... | final_filters() |
Uses mask engine? | Must preserve filter column? |
|---|---|---|---|
| Ignores filters | None (default) |
No | Yes (elimination reads it from your output) |
| Handles everything inline | False |
Yes | No (elimination skipped) |
| Uses inline logic + elimination | True |
Yes | Yes |
| Just forces elimination | True |
No | Yes |
Pipeline data flows
The following examples show what happens to data at each stage for the four patterns above. All flows use the same input and filter:
Input:
region | status | value
A | active | 10
A | inactive | 20
B | active | 30
B | inactive | 40
Filter: status == "active"
Eager framework + final filters (Pandas, PyArrow)
| Stage | Data |
|---|---|
calculate_feature() output |
[10, 20, 30, 40] with status = [active, inactive, active, inactive] |
run_final_filter() |
Creates mask [True, False, True, False], applies table.filter(mask) |
| Final result | [10, 30] with status = [active, active] (2 rows) |
Lazy framework + final filters (DuckDB, SQLite, Polars, Spark)
| Stage | Data |
|---|---|
calculate_feature() output |
Lazy relation: SELECT value, status FROM source |
run_final_filter() |
Appends filter: WHERE "status" = 'active' |
| Materialized result | [10, 30] with status = [active, active] (2 rows) |
The result is identical to eager row elimination, but no intermediate full-table materialization occurs.
Inline masking (all rows preserved)
See the Mask Engine pipeline data flow for details on this pattern.
Masking and elimination (same filter, two FeatureGroups)
A pipeline may need the same filter for two purposes in separate steps. Because filters are deep-copied independently for each FeatureGroup, both steps run without interference:
| Stage | Step 1 (inline mask FG) | Step 2 (regular FG) |
|---|---|---|
final_filters() |
False |
None (engine default: True) |
calculate_feature() |
Uses mask engine, masks values, aggregates | Computes raw values, ignores filters |
run_final_filter() |
Skipped | Applies row elimination |
| Result | [10, 10, 30, 30] (4 rows) |
[10, 30] (2 rows) |