Skip to content

Add column-level evaluation layer and rank proposals in propose - #137

Open
yhong123 wants to merge 13 commits into
mainfrom
yhong123/112-column_evaluators
Open

yhong123 wants to merge 13 commits into
mainfrom
yhong123/112-column_evaluators

Conversation

@yhong123

@yhong123 yhong123 commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Resolved #112

Summary

Adds a column-level evaluation layer that scores and ranks candidate generators against real data, and uses it to improve the propose command in configure-generators.

  • New datafaker.evaluators package: ColumnEvaluator samples a real column, picks an evaluation profile (short text / free text / categorical / identifier) from its statistics, and scores each candidate proposer's synthetic output against it using novelty, diversity, and statistical fidelity metrics (metrics.py, statistical_fidelity.py, feature_extractors.py, distribution_builders.py).
  • proposal_ranking.py: normalises the heterogeneous raw fit scores into a probabilistic ranking and truncates the displayed list with entropy-based cutoff, so propose no longer dumps every candidate unranked.
  • propose command (interactive/generators.py): now surfaces ranked proposals (capped at MAX_PROPOSERS_SHOWN = 10, propose all still shows everything) and filters out continuous-float proposers for genuinely Integer-typed columns, since inserting a non-whole float into an Integer column is backend-dependent.
  • IncrementProposer (proposers/sequence.py): a new proposer for integer primary keys that continues the real data's max value, reusing the same generic.column_value_provider.increment mechanism make.py already uses as its default for integer PKs. (Works towards Add a UniqueSequenceProposer for unique/ID columns — no current proposer guarantees non-duplicate values #134)
  • dialects.py: adds WordCount/SentenceCount (and related) SQL expression helpers, with Postgres/DuckDB and MSSQL compilation, used by the new feature extractors.
  • Docs: new builtin_generators.rst and choosing_a_generator.rst, glossary entries for Generator/Proposer/Role, and TOC updates. (Work towards https://github.com/alan-turing-institute/DataMatryoshka/issues/14)

Implements the "Enhancement to generators selection layer" half of #112 (keyword/ranking improvements to propose, without changing generator/fit logic) plus the groundwork column-level fidelity metrics for the evaluation layer described there. The evaluate command and table/dataset-level utility & privacy metrics from #112 are not part of this PR -- a new issue will be created for this.

Further details on the generator evaluation and ranking methods can be found here: https://safehr-data.github.io/datafaker/builtin_generators.html#evaluating-and-ranking-proposals

Note

@tim-band your original fit-value table is left in the codebase alongside the new ranked table — I didn't want to remove it without your sign-off. Specifically, this PR does not touch:

  • interactive/generators.py:1072-1090 — the old per-proposer loop that prints PROPOSE_GENERATOR_SAMPLE_TEXT using prop.fit(-1)
  • Proposer.fit in proposers/base.py:140, which that loop calls

Once you've reviewed and are happy with the ranking table (RANKED_SAMPLE_TEXT) replacing this output, let me know and I'll remove both in a follow-up — or if you'd rather keep both tables shown side by side permanently, let me know that too and I'll leave them as-is.

Test plan

  • poetry run pytest tests/test_evaluators_*.py — 169 passed
  • Exercise configure-generators → propose interactively against a real table to confirm ranked output and propose all still work as expected

@yhong123
yhong123 requested a review from tim-band September 11, 2026 12:46
@yhong123 yhong123 self-assigned this Sep 11, 2026

@tim-band tim-band left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow! This is incredible! I was blown away by its ability to choose generators for the Artist table for the MOMA database! Of course it had trouble with the Artwork database, choosing username for lots of things but that's probably mostly down to us not having URL or ID Code generators.
The documentation is also fantastic.
I liked the table having colours, but we really need them coloured by score band or something (perhaps just highlighting the recommendation). I really liked seeing the recommendation highlighted when it was in Front 2 or 3, so it was weird that actually this was supposed to be a warning highlight!
This does make propose even slower for multiple columns; perhaps we need to do some work to figure out how bad it is, because it might be that creating the proposers is 90% of it and so it's not a huge deal.
Most of the comments I made aren't a big deal and we shouldn't add months to this because we need to get it into the users' hands!

In general, it looks like you have given too much respect to my existing code and functionality. I'm sure you can do better!

Changes I would like to see before approving this:

  • Fix the help propose text (which is the do_propose docstring). No need yet to add extra help topics if you don't want to, though I would like to see that.
  • Highlight the recommendation.
  • Remove the old output for propose.
  • At least think about taking out the special casing of the float generators being proposed for int columns. Whether we permit floats in int for now or do the actual fix.
  • _get_proposer_proposals must not store the include_all value unless I'm completely wrong about this. I think fixing this requires not filtering out the special cased float generators -- whichever way we go on that.


def analyse_column(values: list[Any]) -> ColumnStats:
"""Compute length/space/digit/punctuation statistics over ``values``."""
values = [str(v) for v in values if v is not None]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels odd. Is the cast to str so that we can take in numbers, dates etc.? What value do we gain from this? I'm imagining floating point ("these have an average of 1 '.' and no spaces"). Is it for safety? This might be OK.

@yhong123 yhong123 Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — it's for safety/generality, though not specifically for numbers/dates: ColumnEvaluator.setup() already routes Numeric/Integer to choose_numeric_profile and Date/DateTime/Time straight to TEMPORAL before anything reaches analyse_column, so your "average of 1 '.' and no spaces" case can't actually occur via that path today.

What does land here is whatever else a non-numeric, non-temporal column type hands back that isn't already a plain str — Boolean, UUID, Enum, JSON, bytes, etc. — which would blow up on len(v)/v.count(" ") without the cast. There's a test pinning this down (test_non_string_values_are_stringified).

I've added a short comment here pointing at the setup() dispatch so the next reader doesn't have to trace it the way I just did.

return EvaluationProfile.SHORT_TEXT

# repeated values
if stats.uniqueness < CATEGORICAL_UNIQUENESS_THRESHOLD:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice.

repeated categories (a status code, rating, or flag) or like a
continuous / high-cardinality quantity (an age, salary, or identifier).

Previously every Integer column was assumed to be categorical and every

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great information for me, but probably not for future readers of the code as they are not thinking about how the code used to be.

@yhong123 yhong123 Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair - dropped the "previously..." framing.


with engine.connect() as conn:
if len(columns) == 1:
# Cap how many real rows we pull into memory so this scales to

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you mean we must do ORDER BY RANDOM before LIMIT? Well yes. This is done elsewhere in DataFaker without comment. If you feel this does need a comment, should we instead move it into dialects.py and call it maybe RandomSample(sample_size)? Then we could put this comment in there and make each ORDER BY RANDOM LIMIT explicitly a random sample operation? What do you think?

@yhong123 yhong123 Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the idea, but it's bigger than this PR - I count ~13 call sites across 11 files (proposers/, evaluators/, interactive/), and one of them (extract.py) doesn't even use .limit(), it does .fetchmany() on the cursor instead, so it'd need an actual rewrite rather than a mechanical rename. I'd rather not scope-creep this PR with an 11-file refactor for something that's pre-existing convention, not new here.

Opened #143 to track it: a random_sample(stmt, sample_size) helper in dialects.py (probably a plain function rather than a ColumnElement like Random/StdDev, since it wraps a statement, not a column), with the comment living there once.

# reproducing them verbatim just leaks real records).
non_null_real_values = [v for v in self.real_values if v is not None]
self.real_uniqueness = (
len({str(v) for v in non_null_real_values}) / len(non_null_real_values)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the str?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same philosophy as the analyse_column cast, but even more necessary here: real_uniqueness is computed unconditionally for every column type (before any Numeric/Date routing happens), so it needs to work on whatever a driver hands back - and it doubles as the canonical representation used to compare real vs. synthetic values elsewhere in this file (see evaluate()'s copy_fraction, which does the same str(canonicalize_value(v)) pattern) - e.g. so an int real value and a str synthetic value that represent the same thing are correctly recognised as a match.

f"{penalty_multipliers[idx]:.3f}",
]
# color entire row for Pareto front 1
if front == 1 and theme is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With my experimentation, most of the table is purple most of the time. Sometime it's all purple, sometimes the line you want is in yellow. I don't think people care that much about the Pareto front (or even what that means), but they might well want the recommended line highlighted. So, I'd prefer setting the recommendation to one theme element and everything else to another theme element; or even put the recommendation in one colour, the near misses in another and the rest in yet another.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added two dedicated theme elements - recommend and near_miss - rather than reusing column/function for this. The recommended row now gets recommend (bright green in both DARK/LIGHT), other Pareto front 1 rows ("near misses" - non-dominated alternatives) get near_miss (blue/cyan), and everything else is left uncoloured, so it's the recommendation vs. near-misses vs. the rest as three distinct, clearly separated colours rather than one colour covering most of the table.

They will be listed in order of fit, the most likely matches first.
The results can be compared (against a sample of the real data in
the column and against each other) with the 'compare' command.

@tim-band tim-band Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstring appears in the response to help propose or ?propose. This paragraph is pretty confusing in this help. We need to re-write this whole docstring to explain what's actually happening now (which isn't obvious). We should definitely include propose all, but also could say the user can type help ranking or help pareto for help on those topics; to allow this we would need to override do_help.

@yhong123 yhong123 Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrote it to explain the current behavior plainly: what the scores mean, that the single best one is highlighted as the recommendation, and the two specific things left out by default (dominated candidates beyond the cap, and type-incompatible ones) with 'propose all' as the escape hatch for both - trying to avoid requiring the reader to already know what "Pareto front" means to follow it.

help ranking/help pareto sub-topics done. Also added one line pointing at the "Evaluating and ranking proposals" doc section (by name, not a Sphinx :ref: - this text prints as plain terminal output via help propose, so cross-ref markup would just show up literally) for anyone who wants the full detail.

Comment thread datafaker/interactive/generators.py Outdated
# which is exactly why the default 'propose' excludes it structurally
# instead of trying to penalize it.
recommended_proposer = results[ranking.recommended_index].proposer
if self._is_integer_incompatible(recommended_proposer, columns):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's get rid of this; it isn't for the user to be scared of proposers that might not work. We should make them work or wait for the issue report.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed, along with the type-compatibility check it was guarding - see the generators.py:839 thread for where that logic ended up instead (a narrower, profile-scoped guard that excludes the candidate from the recommendation entirely rather than warning about it).

Comment thread docs/source/choosing_a_generator.rst Outdated
* - A near-unique value that must look real but not *be* real, e.g. an
email address or free-text ID
- Whichever ``generic.*`` value proposer ``propose`` recommends
- ``propose`` applies an extra privacy penalty here: a candidate that

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "privacy penalty" a known term (can't find it through searching). This sentence reads as though privacy is worsened through this technique. "privacy measure" perhaps?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, couldn't find it as a standard term either - changed to "privacy safeguard".


def test_avg_length_and_ratios(self) -> None:
"""avg_length, space_ratio, digit_ratio and punctuation_ratio are computed."""
stats = analyse_column(["ab 1!"])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe make the three stats that are tested for different numbers, otherwise you aren't testing that they are written to the correct attributes.

@yhong123 yhong123 Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, all three ratios happened to be 1/5 in that example, so a bug that swapped e.g. digit_ratio and space_ratio would still have passed. Changed to "a 1!!!" (2 spaces, 1 digit, 3 punctuation out of 7 chars) so each assertion actually pins down its own attribute.

@yhong123

yhong123 commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

Hi @tim-band Thanks for such a thorough first pass! This pushed the PR somewhere much better than where I left it. Went through every thread. Quick roundup, then the things I’d still like your explicit input on before calling this settled.

Addressed (replied individually on each thread, diffs speak for themselves): help propose rewritten; old unranked output removed; the include_all caching bug fixed by dropping the mode entirely; recommendation now gets its own marker+highlight regardless of Pareto front; ColumnEvaluator.setup() folded into its constructor; propose table trimmed of redundant per-row columns; is_numeric consolidated into db_utils.py; "pipeline" in statistical_fidelity.py renamed to "criteria/criterion". Also turned up and fixed bugs along the way — WeekdayExtractor’s SQL and Python sides disagreed on day-of-week numbering. Filed SAFEHR-data/datafaker#143 / SAFEHR-data/datafaker#144 / SAFEHR-data/datafaker#145 / SAFEHR-data/datafaker#147 for the good ideas that were too big to fold in here.

Still want your input on:

  1. The float/Integer + IncrementProposer tradeoff — I didn’t go with integer-rounding proposers; testing showed that wouldn’t fix the actual problem (fidelity can’t credit a sequence proposer for deliberately exceeding the observed range, int or float). Went with a narrower type guard (Integer+IDENTIFIER-profile only) plus explicit precedence for proposers that structurally guarantee freshness, for PK/UNIQUE columns. Full reasoning’s on that thread (Add column-level evaluation layer and rank proposals in propose #137 (comment)). This is the biggest judgment call in this round, so I’d like your explicit sign-off or pushback.
  2. The new recommend/near_miss theme colours — chosen by reasoning, not your own terminal testing. You mentioned actually experimenting with colours yourself, so I’d like you to eyeball these before we call the highlighting done.
  3. Performance — profiled this properly rather than guessing. Short answer: proposer construction is 1–7% of propose’s cost across every scenario I measured (single columns, merged multi-column), not the ~90% you guessed — the new evaluation/ranking layer is where the time goes. But it’s not the ranking math itself that’s slow: on a 5000-row test table, ordinary proposers evaluated in ~10ms each, while dist_gen.weighted_choice/zipf_choice took ~70ms each — traced with cProfile to weighted_choice() rebuilding its ~500-entry weight list from scratch on every one of the 4000 preview rows, invisible before this PR because the old preview only ever asked for 5 rows. Filed dist_gen.weighted_choice/zipf_choice rebuild their weight tables on every single call #147 for that fix (cheap, low-risk, outside this PR’s diff) — worth checking separately whether real bulk generation hits the same per-row rebuild cost, which could matter a lot more than propose’s preview does.

Re-requesting review once you’ve had a look.

Responds to tim-band's review on PR #137:

- Scope the float-for-Integer type guard to IDENTIFIER-profile columns
  only (CATEGORICAL columns already self-correct on fidelity), computed
  per 'propose' call instead of cached in _get_proposer_proposals
- Give PK/UNIQUE columns precedence to proposers that structurally
  guarantee fresh values (new Proposer.guarantees_fresh_uniqueness(),
  set on IncrementProposer) over the generic fidelity score, which can
  never credit a sequence proposer for deliberately exceeding the
  observed range
- Remove the old unranked per-proposer 'propose' output and the
  include_all caching it required; highlight the recommended row (not
  just Pareto front 1) with dedicated theme colours (recommend/near_miss)
  that remain visible with theming off
- Fold ColumnEvaluator.setup() into its constructor and consolidate
  is_numeric_sql_type into db_utils.py, shared with partitioned.py
- Fix WeekdayExtractor: its SQL and Python sides used disagreeing
  day-of-week numbering conventions, silently comparing mismatched
  category labels for every temporal column; replaced with portable
  floor/modulo arithmetic on the existing days-since-epoch value
- Fix VowelConsonantPatternExtractor miscounting accented vowels as
  consonants (Unicode NFKD base-letter decomposition)
- Rename EvaluationPipeline to EvaluationCriterion (and PIPELINE(S) to
  CRITERION/CRITERIA throughout): these are independent, weighted
  measures combined by weighted average, not a sequential pipeline
- Add 'help ranking'/'help pareto' topics; rewrite the 'propose'
  docstring to match current behaviour
- Assorted docstring/type-hint/wording fixes and test clarity fixes
  raised in review

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@tim-band tim-band left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only one issue; the Theme cannot be null, so we can simplify there.
Great job!
Oh hold on, I didn't answer all your questions. I'll do that tomorrow.
Answers:

  1. Your solution seems good. I would like to talk to you about what exact problems you were seeing though.
  2. Your extra theme entries seem good.
  3. OK, thanks for the research!

So, just the Theme thing.

Comment thread datafaker/interactive/generators.py Outdated
cached.
"""
if include_all is None:
include_all = self.proposers_include_all

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, no I see why it might be correct. I still don't really like it; this extra state makes things much harder to reason about and test correctly.

results: Sequence[ProposalEvaluation],
profile: EvaluationProfile | None,
theme: Any | None = None,
theme: Theme | None = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_active_theme cannot return None, so we could remove the | None and all the resulting ifs

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enhancement to generators selection layer and new evaluation layer

2 participants