Repository navigation
fix(sentence-structure): align feature names, flooring, and rubric boundaries - #404
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
adnanrhussain
left a comment
There was a problem hiding this comment.
@slee430 - After reviewing the code, I think there are more inconsistencies between the SDKs. For example, I think the JSON.stringify to convert the features and send to the model, would not send a 10.0 and instead send a 10. This would be different behavior than the Python impl.
Since most of the code already truncates today, perhaps we could try the alternative path of -
- flooring the raw values to whole numbers
- rubric thresholds to use only < and >=, with ranges written as "≥ a and < b"
- the floor has to apply to the unrounded ratio
This is a subtle change, meeting the original intent of the rubric, but changing its current behavior, so will require re-running the tests you validated for this PR, but now with this change.
| * **Statistical Guidelines:** The text is likely "Slightly Complex" if it meets at least TWO of the following criteria: | ||
| * **Sentence Type:** Primarily simple sentences. (`percent_simple_sentences` is typically > 60%). | ||
| * **Sentence Length:** Short sentences. (`avg_sentence_length` is typically < 12 words). | ||
| * **Sentence Length:** Short sentences. (`avg_words_per_sentence` is typically < 12 words). |
There was a problem hiding this comment.
@slee430 - To confirm, the code was already sending avg_words_per_sentence, but the rubric was referencing avg_sentence_length. The model was inferring on this own, and silently resolving this bug?
There was a problem hiding this comment.
@adnanrhussain Yes, the code was already sending avg_words_per_sentence, but the grade 3 and 4 rubrics referenced avg_sentence_length. The model may have inferred the connection, but we cannot confirm that it did so consistently. This fix makes the reference explicit.
There was a problem hiding this comment.
Regarding your alternative approach, I’ll keep the current implementation for comparison and test your proposal in a separate branch across Python, TypeScript, and the notebook. I’ll keep the feature-name fix in both versions and make the rubric boundary changes explicit.
Then I’ll rerun the tests and compare the baseline, the current PR, and your proposal using the same extracted features. I’ll check the grade 4 results closely, since accuracy decreased there in the previous comparison. We can review the results before deciding which approach to use in this PR.
There was a problem hiding this comment.
Hi @adnanrhussain @slee430 -- I've looked at the code and also viewed Seung Yeon's tests. Just to confirm my understanding: Adnan's approach means that the issue Seung Yeon was originally aiming to fix (i.e. some variables such as percentages get truncated) will not be resolved, to keep things consistent with the SDK. Is that understanding correct?
On my end, I'm fine with this for now and I think it's something we should flag for fixing later on.
adnanrhussain
left a comment
There was a problem hiding this comment.
Conditional approval, requested changes pls. TY
| def features_to_json(features: Mapping[str, Any]) -> str: | ||
| def features_to_json( | ||
| features: Mapping[str, Any], *, cast_to_int: bool = True, floor_values: bool = False | ||
| ) -> str: | ||
| """:data:`FEATURE_COLS` as the JSON object bound to ``{sentence_features}``. | ||
|
|
||
| Every value is a whole number: the rubric's thresholds are written in whole percentages | ||
| and averages, and the fractional place the model would otherwise read invites precision | ||
| it cannot support. A feature the analysis did not produce is sent as ``null`` rather | ||
| than zero, so a missing count reads as missing rather than as "none found". | ||
| ``floor_values=True`` floors the supplied values without preliminary rounding and | ||
| takes precedence over ``cast_to_int``. Supply unrounded features for this mode. | ||
| Otherwise, ``cast_to_int=False`` retains one decimal place. Defaults preserve the | ||
| legacy serializer API. | ||
| A feature the analysis did not produce is sent as ``null`` rather than zero. | ||
| """ | ||
| payload: dict[str, int | None] = {} | ||
| payload: dict[str, int | float | None] = {} | ||
| for name in FEATURE_COLS: | ||
| value = features.get(name) | ||
| if isinstance(value, bool) or not isinstance(value, (int, float)): | ||
| payload[name] = None | ||
| elif isinstance(value, int): | ||
| payload[name] = value | ||
| elif floor_values: | ||
| payload[name] = math.floor(value) | ||
| else: | ||
| # Truncated, not rounded: this is the notebook's arithmetic, and the fixtures | ||
| # and thresholds this evaluator was validated against were produced by it. | ||
| payload[name] = int(value) | ||
| payload[name] = int(value) if cast_to_int else round(value, PRECISION) | ||
| return json.dumps(payload, indent=2) |
There was a problem hiding this comment.
P0 - Can we pls remove the cast_to_int since it was introduced in this PR but not used now
There was a problem hiding this comment.
Removed cast_to_int from the Python helper in 82d5ee5. The default still truncates to integers, and the evaluator still uses floor_values=True. I also checked that the full model requests are unchanged for all 407 saved cases.
| @pytest.mark.parametrize( | ||
| ("name", "value", "expected"), | ||
| [ | ||
| ("avg_transitions_per_sentence", 0.20, 0.2), | ||
| ("avg_subordinates_per_sentence", 0.43, 0.4), | ||
| ("appositive_phrase_density", 0.52, 0.5), | ||
| ("participle_phrase_density", 0.29, 0.3), | ||
| ("percent_simple_sentences", 60.5, 60.5), | ||
| ], | ||
| ) | ||
| def test_fractional_signals_survive_serialization(name: str, value: float, expected: float) -> None: | ||
| payload = json.loads(features_to_json({name: value}, cast_to_int=False)) | ||
| assert payload[name] == expected | ||
| assert payload["num_sentences"] is None |
There was a problem hiding this comment.
Can be removed, since we no longer use the rounding to implementation
There was a problem hiding this comment.
Removed the test for cast_to_int=False in 82d5ee5. The flooring and boundary tests are still in place.
| @@ -83,7 +83,7 @@ export class SentenceStructureEvaluator extends defineMultiStepEvaluator< | |||
| calculateReadabilityMetrics: (text) => formatGroundTruthCounts(String(text)), | |||
| // Cast to int and single-space, matching the Python implementation's JSON. | |||
There was a problem hiding this comment.
Now stale-comment. please update
There was a problem hiding this comment.
Updated the comment in 82d5ee5. It now says that we floor unrounded values before serialization, matching the Python evaluator.
| // Cast to int and single-space, matching the Python implementation's JSON. | ||
| addEngineeredFeatures: (analysis) => | ||
| featuresToJSON(addEngineeredFeatures(analysis as SentenceAnalysis), 1, true), | ||
| featuresToJSON(addEngineeredFeatures(analysis as SentenceAnalysis), 1, true, true), |
There was a problem hiding this comment.
If the last arg floorValues is true, it would short-circuit and ignore the decimals 1 and castToInt true.
We should refactor this for better readability, but its outside the scope of this PR. Can you please add a TODO over to update with calling with objects?
There was a problem hiding this comment.
Added the TODO in 82d5ee5 to use an options object. It also notes that floorValues=true ignores decimals and castToInt. The refactor is left for a separate change.
| # truncate-versus-round question it belongs with. | ||
| features.update({name: round(value, PRECISION) for name, value in derived.items()}) | ||
| # Preserve the notebook's ties-to-even rounding; cross-SDK rounding alignment | ||
| # is tracked separately in DSCR-2223. |
There was a problem hiding this comment.
Updated the comment in 82d5ee5. It now explains that default callers keep one-decimal rounding, while the sentence-structure evaluator uses round_values=False before flooring.
aychi1
left a comment
There was a problem hiding this comment.
LGTM. One minor comment (edge case) on a prompt.
Summary
The grade 3–4 rubrics use
avg_sentence_length, but the model receivesavg_words_per_sentence. Python and TypeScript also convert numeric features differently.This PR fixes the feature-name mismatch and applies the same floor approach in both SDKs and the notebook.
Changes
<and>=in the grade 3–4 rubrics. Write ranges as>= a and < b.For example,
44.96%becomes44and stays below45%. Changing> 60%to>= 60%also means exactly60%now meets that criterion.Validation
make verifypassed: 1,755 tests passed; 108 skipped.Classification comparison
Each version was run three times on 402 grade 3–4 rows using Sentence Score as the reference label. The runs used
gpt-4o-2024-08-06, temperature 0, and the same saved grammatical analyses.Compared with the initial PR, five grade 3 cases and one grade 4 case became incorrect in all three runs. The five repository fixture/integration cases had unchanged labels across versions and runs.
The overall gain does not establish a general accuracy improvement. These runs did not use held-out data or repeat the grammatical-analysis step.
Known limitations and follow-up
A floating-point issue remains in both SDKs and the notebook. For example,
29 / 100 * 100can produce28.999999999999996, which floors to 28 instead of 29. This issue was not found in the 407 comparison cases.Flooring also removes fractional signals: an average of
0.4subordinate clauses per sentence becomes0. The current rubrics do not set explicit thresholds for this feature, so this behavior is kept for now as a known limitation.Further review and revision are needed for the calculation issue, the usefulness of fractional features, and the persistent classification regressions. Validation on held-out data remains a follow-up.