The StagedEvaluator extends the bid evaluation library with sequential, multi-stage evaluation. Bids are scored in stages, and a configurable filter between stages eliminates underperforming bids before the next round.
Many real-world procurement processes work in phases:
- Technical screening -- reject bids that don't meet a minimum quality bar.
- Shortlisting -- narrow the field to the top N candidates.
- Economic evaluation -- rank the survivors on price.
StagedEvaluator models this directly. Each stage has its own criteria and scoring, and an optional filter controls which bids advance.
Install from PyPI:
pip install bid-evaluationIf you want to run Streamlit demos from the repository:
pip install ".[demos]"import pandas as pd
from bid_evaluation import StagedEvaluator
bids = pd.DataFrame({
'vendor': ['Alpha', 'Beta', 'Gamma', 'Delta'],
'experience': [12, 4, 9, 2],
'quality_score': [85, 55, 92, 45],
'bid_amount': [100_000, 88_000, 115_000, 82_000],
})
result = (StagedEvaluator()
.add_stage('Technical', filter_type='score_threshold', threshold=60)
.linear('experience', 0.4, higher_is_better=True)
.direct('quality_score', 0.6)
.add_stage('Economic')
.min_ratio('bid_amount', 1.0)
.evaluate(bids))
print(result[['vendor', 'technical_score', 'economic_score',
'eliminated_at_stage', 'final_score', 'ranking']])Stage 1 Stage 2 Stage 3
+-----------+ +-----------+ +-----------+
| Evaluate | | Evaluate | | Evaluate |
| all bids |--filter-->surviving |--filter-->surviving |-->final ranking
| | | bids only | | bids only |
+-----------+ +-----------+ +-----------+
| |
v v
eliminated eliminated
(NaN ranking) (NaN ranking)
- Each stage evaluates only the bids that haven't been eliminated yet.
- After scoring, the stage's filter decides who advances (except on the last stage).
- Eliminated bids are tagged with the stage that removed them.
- The final ranking is computed from the surviving bids only.
staged = (StagedEvaluator()
.add_stage('Technical', filter_type='score_threshold', threshold=60)
.linear('experience', 0.4, higher_is_better=True)
.direct('quality_score', 0.6)
.add_stage('Economic')
.min_ratio('bid_amount', 1.0))
result = staged.evaluate(bids_df)After calling .add_stage(...), all subsequent criterion methods (.linear(), .direct(), .min_ratio(), .threshold(), .formula(), .custom()) add criteria to that stage. Calling .add_stage(...) again starts a new stage.
config = {
'stages': [
{
'name': 'Technical',
'filter': {'type': 'score_threshold', 'threshold': 60},
'criteria': {
'experience': {'type': 'linear', 'weight': 0.4, 'higher_is_better': True},
'quality_score': {'type': 'direct', 'weight': 0.6}
}
},
{
'name': 'Economic',
'criteria': {
'bid_amount': {'type': 'min_ratio', 'weight': 1.0}
}
}
]
}
result = StagedEvaluator.from_config(config).evaluate(bids_df)result = StagedEvaluator.from_yaml('evaluation.yaml').evaluate(bids_df)
result = StagedEvaluator.from_json('evaluation.json').evaluate(bids_df)Example YAML:
stages:
- name: Technical
filter:
type: top_n
top_n: 5
criteria:
experience:
type: linear
weight: 0.4
higher_is_better: true
quality_score:
type: direct
weight: 0.6
- name: Economic
criteria:
bid_amount:
type: min_ratio
weight: 1.0| Parameter | Type | Default | Description |
|---|---|---|---|
name |
str |
(required) | Stage name, used in output column prefixes |
filter_type |
str or None |
None |
'score_threshold', 'top_n', or None (no filter) |
threshold |
float |
None |
Minimum score to advance (required when filter_type='score_threshold') |
top_n |
int |
None |
Number of top bids to advance (required when filter_type='top_n') |
on_tie |
str |
'include' |
Tie-breaking at cutoff: 'include' or 'exclude' |
weight |
float |
1.0 |
Stage weight (used only in weighted_combination mode) |
Score Threshold -- bids must score at or above the threshold:
.add_stage('Technical', filter_type='score_threshold', threshold=60)Top N -- only the N highest-scoring bids advance:
.add_stage('Shortlist', filter_type='top_n', top_n=5)No Filter -- all bids advance (omit filter_type or set to None):
.add_stage('Final')When using top_n, ties at the cutoff boundary need a policy:
on_tie='include'(default) -- all tied bids at the Nth rank advance. The number of advancing bids may exceed N.on_tie='exclude'-- only bids strictly above the tied score advance. The number of advancing bids may be fewer than N.
# Scores: A=90, B=80, C=80, D=70 with top_n=2
# include: A, B, C advance (3 bids — both rank-2 bids included)
# exclude: only A advances (1 bid — the tie at rank 2 is excluded)The final score equals the last stage's score. Earlier stages serve purely as filters.
StagedEvaluator() # defaults to final_score_mode='last_stage'The final score is a weighted average of all stage scores. Set per-stage weights via the weight parameter on add_stage().
result = (StagedEvaluator(final_score_mode='weighted_combination')
.add_stage('Technical', filter_type='score_threshold', threshold=60, weight=0.6)
.direct('quality_score', 1.0)
.add_stage('Economic', weight=0.4)
.min_ratio('bid_amount', 1.0)
.evaluate(bids_df))
# final_score = technical_score * 0.6 + economic_score * 0.4Weights are normalized to sum to 1.0.
The result DataFrame contains all original columns plus:
| Column | Description |
|---|---|
{name}_score |
Aggregate score for that stage |
{name}_ranking |
Ranking within that stage |
{name}_{criterion} |
Per-criterion score (when include_details=True) |
eliminated_at_stage |
Name of the stage that eliminated the bid, or None |
final_score |
Final score (from last stage or weighted combination) |
ranking |
Final ranking (NaN for eliminated bids) |
The output is sorted with ranked bids first (ascending by rank), followed by eliminated bids.
staged = (StagedEvaluator()
.add_stage('Technical', filter_type='score_threshold', threshold=60)
.linear('experience', 0.4, higher_is_better=True)
.direct('quality_score', 0.6)
.add_stage('Economic')
.min_ratio('bid_amount', 1.0))
# Before evaluation: view configured stages and criteria
staged.summary()
# stage stage_weight filter column criterion_type criterion_weight
# Technical 1.0 score >= 60.0 experience LinearCriterion 0.4
# Technical 1.0 score >= 60.0 quality_score DirectScoreCriterion 0.6
# Economic 1.0 None bid_amount MinimumRatioCriterion 1.0
# After evaluation
result = staged.evaluate(bids_df)
# Per-stage statistics (min, max, mean, std, etc.)
staged.get_statistics()
# Detailed StageResult objects with advanced/eliminated indices
staged.get_stage_results()| Scenario | Behavior |
|---|---|
| All bids eliminated at a stage | A warning is issued. Subsequent stages are skipped. All bids get NaN for final_score and ranking. |
| Single stage, no filter | Behaves identically to the plain Evaluator. |
| Empty input DataFrame | Returns an empty DataFrame with all expected output columns. |
| Eliminated bids | Get NaN ranking and NaN for stages they never reached. |
# Staged evaluator tests (29 tests)
pytest tests/test_staged.py -v
# All tests
pytestpython examples/example_staged.pyStagedEvaluator uses composition: it creates one Evaluator instance per stage internally. The existing criteria.py module is not modified. All stage-specific logic lives in bid_evaluation/staged.py.
StagedEvaluator
|
+-- StageDefinition("Technical")
| +-- Evaluator (with LinearCriterion, DirectScoreCriterion)
| +-- StageFilter(type='score_threshold', threshold=60)
|
+-- StageDefinition("Economic")
+-- Evaluator (with MinimumRatioCriterion)
+-- (no filter -- last stage)