Skip to content

effector.proposers

The proposer protocol

Inside the built-in finders, candidate enumeration is its own seam: a proposer is any object exposing

propose(ctx: SearchContext, foc: int) -> list[CandidateSplit]

where each CandidateSplit is an ordered tuple of disjoint, jointly-covering Conditions on the conditioning feature foc — a split expressed as parent rule → child conditions. Child i of a parent region is parent_rule.refine(conditions[i]) with mask parent_mask & conditions[i].contains(data); candidates are parent-independent (a level-wise finder applies one candidate to every node of a level) and k-way by construction (len(conditions) >= 2).

The defaults reproduce the classic search — ContinuousThreshold (binary x < t / x >= t on an interior grid) and CategoricalOneVsRest ({v} vs the explicit complement over the observed levels). The richer built-ins are selected by name from a finder's constructor:

effector.space_partitioning.Best(
    categorical_proposer="ordered",   # "one_vs_rest" | "subsets" | "ordered" | "multiway"
    continuous_proposer="quantiles",  # "threshold" | "quantiles"
)

Both kwargs also accept a proposer instance (for non-default parameters, e.g. CategoricalOrdered(order=[2.0, 0.0, 1.0])), and any custom object exposing propose. The raw seam underneath is the finder's proposer_factory attribute (feature type -> proposer), which make_proposer_factory builds from the two kwargs.

API

effector.proposers.SearchContext(data, axis_limits, feature_types, numerical_grid_size) dataclass

Everything a proposer may read; built once per split search.

effector.proposers.CandidateSplit(conditions) dataclass

An ordered tuple of disjoint, jointly-covering conditions on one feature — the children of any parent split by this candidate.

effector.proposers.ContinuousThreshold

Binary threshold splits — the continuous default ("threshold").

One candidate (x < t, x >= t) per interior position t of a uniform grid over the axis limits (numerical_features_grid_size segments, so grid_size - 1 candidates).

Methods:

Name Description
propose

propose(ctx, foc)

Source code in effector/proposers.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def propose(self, ctx: SearchContext, foc: int) -> list:
    lo, hi = ctx.axis_limits[0, foc], ctx.axis_limits[1, foc]
    positions = np.linspace(lo, hi, ctx.numerical_grid_size + 1)[1:-1]
    return [
        CandidateSplit(
            (
                Condition(foc, Interval(hi=t)),  # mask: x < t
                Condition(foc, Interval(lo=t)),  # mask: x >= t
            )
        )
        for t in positions
    ]

effector.proposers.CategoricalOneVsRest

One level vs all the others — the categorical default ("one_vs_rest").

One binary candidate ({v}, universe - {v}) per observed level v, ascending. The complement is materialized as an explicit LevelSet over the observed universe (this proposer owns the != semantics).

Methods:

Name Description
propose

propose(ctx, foc)

Source code in effector/proposers.py
118
119
120
121
122
123
124
125
126
127
128
def propose(self, ctx: SearchContext, foc: int) -> list:
    universe = set(_observed_levels(ctx, foc))
    return [
        CandidateSplit(
            (
                Condition(foc, LevelSet({v})),  # mask: x == v
                Condition(foc, LevelSet(universe - {v})),  # mask: x != v
            )
        )
        for v in sorted(universe)
    ]

effector.proposers.CategoricalSubsets(max_levels=8)

Every binary subset-vs-complement split over the observed levels ("subsets").

Each unordered {S, complement} pair appears exactly once: the smallest level is pinned to the first child, subsets enumerate size-ascending (ties in the finder's argmin therefore prefer simpler splits), lexicographic within a size — so the first candidates are exactly the one-vs-rest singletons.

Exponential in the level count

2^(K-1) - 1 candidates for K levels: above max_levels the proposal degrades to the one-vs-rest list with a UserWarning — the feature stays searchable instead of silently vanishing from the candidate set.

Initialize the proposer.

Parameters:

Name Type Description Default
max_levels int

Above this many observed levels, fall back to the one-vs-rest candidates (with a warning). Must be >= 2.

8

Methods:

Name Description
propose
Source code in effector/proposers.py
161
162
163
164
165
166
167
168
169
170
def __init__(self, max_levels: int = 8):
    """Initialize the proposer.

    Args:
        max_levels: Above this many observed levels, fall back to the
            one-vs-rest candidates (with a warning). Must be >= 2.
    """
    if max_levels < 2:
        raise ValueError(f"max_levels must be >= 2; got {max_levels}")
    self.max_levels = max_levels

propose(ctx, foc)

Source code in effector/proposers.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def propose(self, ctx: SearchContext, foc: int) -> list:
    universe = _observed_levels(ctx, foc)
    if len(universe) < 2:
        return []
    if len(universe) > self.max_levels:
        warnings.warn(
            f"feature {foc} has {len(universe)} observed levels "
            f"(> max_levels={self.max_levels}); proposing only the "
            f"one-vs-rest subsets"
        )
        return CategoricalOneVsRest().propose(ctx, foc)
    head, rest = universe[0], universe[1:]
    return [
        CandidateSplit(
            (
                Condition(foc, LevelSet({head, *combo})),
                Condition(foc, LevelSet(set(universe) - {head, *combo})),
            )
        )
        for r in range(len(rest))  # |S| - 1, ascending; complement nonempty
        for combo in itertools.combinations(rest, r)
    ]

effector.proposers.CategoricalOrdered(order='auto')

Contiguous cuts after ordering the levels ("ordered").

K - 1 binary prefix/suffix splits for K observed levels — both sides explicit LevelSets. Linear in the level count, so it scales where CategoricalSubsets explodes; the price is that only order-contiguous groupings are reachable.

Initialize the proposer.

Parameters:

Name Type Description Default
order

How to order the levels, resolved per feature at propose time:

  • "auto" (default): natural ascending for ordinal features, effector.ordering.similarity_order seriation for nominal ones;
  • "natural" / "similarity": force one of the two;
  • an explicit sequence of level values: used as-is, restricted to the observed levels (an observed level missing from it raises).
'auto'

Methods:

Name Description
propose
Source code in effector/proposers.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def __init__(self, order="auto"):
    """Initialize the proposer.

    Args:
        order: How to order the levels, resolved per feature at propose
            time:

            - `"auto"` (default): natural ascending for ordinal features,
              `effector.ordering.similarity_order` seriation for nominal ones;
            - `"natural"` / `"similarity"`: force one of the two;
            - an explicit sequence of level values: used as-is, restricted
              to the observed levels (an observed level missing from it
              raises).
    """
    if isinstance(order, str):
        if order not in self._ORDER_STRINGS:
            raise ValueError(
                f"order must be one of {self._ORDER_STRINGS} or a "
                f"sequence of level values; got {order!r}"
            )
        self.order = order
    else:
        levels = tuple(float(v) for v in order)
        if any(math.isnan(v) for v in levels):
            raise ValueError("an explicit order must not contain NaN")
        self.order = levels

propose(ctx, foc)

Source code in effector/proposers.py
256
257
258
259
260
261
262
263
264
265
266
267
268
269
def propose(self, ctx: SearchContext, foc: int) -> list:
    universe = _observed_levels(ctx, foc)
    if len(universe) < 2:
        return []
    levels = self._ordered_levels(ctx, foc, universe)
    return [
        CandidateSplit(
            (
                Condition(foc, LevelSet(levels[:i])),
                Condition(foc, LevelSet(levels[i:])),
            )
        )
        for i in range(1, len(levels))
    ]

effector.proposers.CategoricalMultiway

A single k-way candidate: one child per observed level ("multiway").

One LevelSet({v}) child per observed level, ascending. Fewer than 2 observed levels proposes nothing (a 1-condition candidate is not a split).

Methods:

Name Description
propose

propose(ctx, foc)

Source code in effector/proposers.py
138
139
140
141
142
def propose(self, ctx: SearchContext, foc: int) -> list:
    universe = _observed_levels(ctx, foc)
    if len(universe) < 2:
        return []
    return [CandidateSplit(tuple(Condition(foc, LevelSet({v})) for v in universe))]

effector.proposers.ContinuousQuantiles(max_children=5)

K-way splits at the marginal quantiles ("quantiles").

One k-way candidate per child count k in 2..max_children, ascending (ties in the finder's argmin therefore prefer fewer children): the children cut the conditioning column at its quantiles i/k — a jointly-covering chain of half-open Intervals over (-inf, inf).

Edges are data-driven (x-only): duplicate quantiles collapse, so a candidate may end up with fewer than k children; edges at the column minimum are dropped (they would bound an empty first child — a constant column therefore proposes nothing); identical edge chains produced by different k are proposed once. Both ctx.numerical_grid_size (the threshold-grid knob) and ctx.axis_limits are ignored — max_children is this proposer's own knob and the unbounded chain covers any axis.

Initialize the proposer.

Parameters:

Name Type Description Default
max_children int

Largest child count to propose (one candidate per k in 2..max_children). Must be >= 2.

5

Methods:

Name Description
propose
Source code in effector/proposers.py
290
291
292
293
294
295
296
297
298
299
def __init__(self, max_children: int = 5):
    """Initialize the proposer.

    Args:
        max_children: Largest child count to propose (one candidate per
            ``k`` in ``2..max_children``). Must be >= 2.
    """
    if max_children < 2:
        raise ValueError(f"max_children must be >= 2; got {max_children}")
    self.max_children = max_children

propose(ctx, foc)

Source code in effector/proposers.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def propose(self, ctx: SearchContext, foc: int) -> list:
    col = ctx.data[:, foc]
    candidates, seen = [], set()
    for k in range(2, self.max_children + 1):
        edges = np.unique(np.quantile(col, np.arange(1, k) / k))
        edges = tuple(float(e) for e in edges if col.min() < e)
        if not edges or edges in seen:
            continue
        seen.add(edges)
        bounds = [-np.inf, *edges, np.inf]
        candidates.append(
            CandidateSplit(
                tuple(
                    Condition(foc, Interval(lo=lo, hi=hi))
                    for lo, hi in zip(bounds[:-1], bounds[1:])
                )
            )
        )
    return candidates

effector.proposers.default_proposer(feature_type)

The finder default: one-vs-rest for categorical conditioning features, binary threshold for continuous ones.

Source code in effector/proposers.py
322
323
324
325
326
327
def default_proposer(feature_type: str):
    """The finder default: one-vs-rest for categorical conditioning features,
    binary threshold for continuous ones."""
    if ingestion.is_categorical(feature_type):
        return CategoricalOneVsRest()
    return ContinuousThreshold()

effector.proposers.make_proposer_factory(categorical='one_vs_rest', continuous='threshold')

Build a finder's feature type -> proposer factory from one categorical and one continuous proposer spec (names or instances).

Source code in effector/proposers.py
361
362
363
364
365
366
367
368
369
370
371
372
def make_proposer_factory(categorical="one_vs_rest", continuous="threshold"):
    """Build a finder's ``feature type -> proposer`` factory from one
    categorical and one continuous proposer spec (names or instances)."""
    categorical = resolve_proposer(categorical, CATEGORICAL_PROPOSERS, "categorical")
    continuous = resolve_proposer(continuous, CONTINUOUS_PROPOSERS, "continuous")

    def factory(feature_type: str):
        if ingestion.is_categorical(feature_type):
            return categorical
        return continuous

    return factory