API Reference#

Readability#

ReadabilityAnalyzer#

Class for analyzing text readability with math-aware normalization.

from mathipy import ReadabilityAnalyzer

analyzer = ReadabilityAnalyzer()
result = analyzer.analyze("Solve for x: 2x + 5 = 15")

Returns a dictionary with:

flesch_reading_ease

Flesch Reading Ease score (0–100)

flesch_kincaid_grade

Flesch-Kincaid grade level

gunning_fog

Gunning Fog index

smog_index

SMOG index

automated_readability_index

ARI score

coleman_liau_index

Coleman-Liau index

linsear_write_formula

Linsear Write formula

dale_chall_readability

Dale-Chall readability score

average_grade_level

Average of FK, Fog, and SMOG

low_confidence

True if text is shorter than 20 words

Math Content#

MathContentAnalyzer#

Class for math content analysis and CCSSM domain classification.

from mathipy import MathContentAnalyzer

analyzer = MathContentAnalyzer()
result = analyzer.analyze("What is the area of a triangle with base 6 and height 4?")

Returns a dictionary with:

pattern_matches

Detected math patterns (equations, fractions, etc.)

symbol_counts

Counts of math symbols by type

total_math_symbols

Total math symbols found

numbers

Extracted numbers with count, range, and properties

vocabulary

Matched math terms and counts

domain_classification

Primary domain, confidence, and scores

math_density

Ratio of math patterns to word count

Domain categories: arithmetic, algebra, geometry, statistics, calculus, fractions

Cognitive Load#

CognitiveLoadEstimator#

Class for estimating cognitive load components.

from mathipy import CognitiveLoadEstimator

estimator = CognitiveLoadEstimator()
result = estimator.estimate(text, readability_grade=5.2, math_terms=["equation", "solve"])

Returns a dictionary with:

numeric_elements

Count of numbers in text

variable_count

Count of single-letter variables

operation_count

Count of math operations (+, -, *, /, ^, =, <, >)

word_count

Total words in text

sentence_count

Total sentences in text

math_term_count

Count of CCSSM-aligned math keywords

element_density

Ratio of (numeric elements + variables) to word count

avg_sentence_length

Average words per sentence

Visual#

VisualFeatureExtractor#

Class for extracting complexity features from assessment images.

Requires: pip install mathipy[vision]

from mathipy import VisualFeatureExtractor

extractor = VisualFeatureExtractor()
features = extractor.extract("item_image.png")

Accepts a file path, Path object, or numpy array. Returns a dictionary with:

dimensions

Width, height, aspect ratio, channels

pixel_statistics

Mean, std, min, max, median, contrast

edge_metrics

Canny edge ratio, Sobel/Laplacian statistics

structural_elements

Detected lines, circles, shapes (triangles, rectangles, etc.)

frequency_domain

Low/mid/high frequency energy ratios

complexity_score

Summary with edge_ratio, total_shapes, and high_freq_ratio

OCR#

MultimodalOCR#

Class for extracting text and math from images using vision LLMs.

Requires: pip install mathipy[ocr] and a GEMINI_API_KEY or OPENAI_API_KEY.

from mathipy import MultimodalOCR

ocr = MultimodalOCR(provider="gemini")
result = ocr.extract("item_image.png")

Parameters:

provider

"gemini" or "openai"

model

Model name (defaults to gemini-2.5-flash or gpt-4o)

api_key

API key (or set via .env file)

Accepts image path, URL, bytes, PDF, DOCX, or text file. Returns a dictionary with:

content_type

text_only, image_only, or mixed

full_text

All extracted text

image_description

Visual content description

question_text

Main question/problem statement

math_expressions

List of LaTeX expressions

answer_choices

Dictionary of answer choices

extraction_confidence

Confidence score (0–1)

Visual Model Classification#

VisualModelClassifier#

Classify which visual model types appear in an assessment image.

Requires: pip install mathipy[ocr] and a GEMINI_API_KEY or OPENAI_API_KEY.

from mathipy import VisualModelClassifier

classifier = VisualModelClassifier(provider="gemini")
result = classifier.classify("item_image.png")
result = classifier.classify("item_image.png", votes=3)
result = classifier.classify("item_image.png", item_text="A store sells 3 apples for $2...")

Parameters:

provider

"gemini" or "openai"

model

Model name (defaults to gemini-2.5-flash or gpt-4o)

api_key

API key (or set via .env file)

Returns a dictionary with a boolean per model type, "primary" (str), "function" (str: essential, representational, decorative, or unknown), and "model_count" (int). The votes argument runs multiple independent calls and merges flags by majority and primary/function by mode.

The item_text argument supplies the item’s text so the function judgment can compare the image against it. Without item_text, the essential vs representational distinction is reliable only when the image contains the complete item.

visual_model_definitions#

Dictionary with a one-line decision rule for each type. The same rules appear in the classifier prompt, so human coders and the model work from a single codebook.

from mathipy import visual_model_definitions
print(visual_model_definitions["box_plot"])

visual_model_groups#

Dictionary mapping each visual model type to a broader category.

from mathipy import visual_model_groups
print(visual_model_groups["number_line"])  # "number_quantity"

visual_model_info#

List of tuples with (model_name, group, CCSSM_domains, grade_band) for each visual model type.

from mathipy import visual_model_info
for name, group, domains, grades in visual_model_info[:3]:
    print(f"{name}: {group}, {domains}, {grades}")

flags_by_group#

Collapses the per-type boolean flags into one visual_group_* key per representation family. Several types are rare in any single corpus, so the families give a workable predictor set for modeling while the per-type flags remain available for description.

from mathipy import flags_by_group, group_names

print(group_names)
# ['data_display', 'geometric', 'number_quantity', 'organizational',
#  'other_visual', 'part_whole_model']

print(flags_by_group(result))
# {'visual_group_data_display': 1, 'visual_group_geometric': 0, ...}

Keys other than the type names are ignored, so a full classification result can be passed directly.

Multimodal Item#

MultimodalAnalyzer#

Runs every analyzer over one item and returns a flat prefix_* dict. Aliased as ItemFeatureExtractor.

from mathipy import MultimodalAnalyzer

row = MultimodalAnalyzer().extract("Which fraction is larger, 2/3 or 3/4?")

Notation#

normalize_math_notation#

Converts LaTeX and hybrid-ASCII math markup to one plain-text grammar, so the same expression is not counted as several different things.

Morphology#

morphology_features#

Counts of meaning-bearing morphemes below the word: nominalization, Greek and Latin roots, derivational suffixes.

Symbolic Notation#

symbolic_features#

Counts of relations carried by notation rather than by wording. channel_pairs names the sym_* measures that have a rel_* counterpart, so the two channels can be compared.

Fractions#

fraction_features#

Structural features of every fraction in the text, in digit or word form: denominator magnitude, unit fractions, unlike denominators, unreduced forms.

Cross-modal Reference#

crossmodal_features#

Every cross-modal measure for one item. deictic_features counts pointers to the image (“shown above”); label_features counts references to a labeled element inside it (“angle A”).

Geometry#

classify_shapes#

Counts of geometric shape subtypes from contour analysis: triangle and quadrilateral subtypes, regular polygons, fill and partition structure.

Feature Redundancy#

dependent_sets#

Every composite in a column set whose components are also present, so a caller can see what would make a correlation matrix singular. drop_composites and drop_components apply the two reductions; composite_features and near_duplicates declare the structure.

Figure Region#

crop_to_box#

Crops an image to a figure box and returns the region measured, "figure" or "frame". trim_margin removes capture margin so a ratio does not depend on how much white surrounds the item.

flags_by_group, flags_by_sign#

Collapse the per-type flags into representation families (group_names) or Peircean sign classes (sign_names, visual_model_signs). visual_functions lists the three instructional function labels.

Document Segmentation#

Released test documents hold many items in one file, marked by an identifier line. Whether that line precedes or follows the block it names varies by publisher, and both readings parse without error — so a wrong choice is silent and shifts every item’s content by one position.

from mathipy import segment_docx, check_alignment

items = segment_docx(
    "released-test.docx",
    marker="Question ID:",
    label_position="trailing",
    skip_prefixes=["_____", "Item Detail"],
    section_markers={"Item Detail - Question Description": "description"},
)

label_position is "leading" when the marker introduces the block below it, "trailing" when it names the block above. section_markers routes text under a named header into its own field, so descriptive metadata does not contaminate the item body. Each record holds item_id, text, images, and any named sections.

Confirm the reading before an analysis depends on it:

check_alignment(items, reference={item_id: expected_description})
# {'own': 1180, 'next': 84, 'previous': 6, 'compared': 1270,
#  'proportions': {...}, 'verdict': 'aligned'}

A verdict of shifted_next means identifiers lag the content by one position — switch label_position and re-check. Comparing image counts between the two readings is a second signal: the wrong reading silently drops content that precedes the first marker.

Cohesion#

Discourse measures for extended writing and dialogue, where readability formulas do not apply. Deterministic, standard library only, no network call.

from mathipy import cohesion_features

cohesion_features("The slope rises. However it falls later. Therefore we checked.")
# {'connective_additive': 0, 'connective_causal': 1, 'connective_adversative': 1,
#  'connective_total': 2, 'connective_per_100w': 16.7,
#  'overlap_adjacent_mean': 0.25, 'lexical_diversity': 0.85,
#  'pronoun_per_100w': 8.3, 'unit_count': 3}

Accepts a string, split into sentences, or a sequence of units such as turns from segment_turns.

Individual measures are available as connective_density, lexical_overlap, lexical_diversity, and pronoun_density. Connectives are grouped as additive, causal, temporal, adversative, and clarifying, matched longest-phrase-first so on the other hand is not double-counted as but. Lexical diversity uses a moving-average ratio so it does not fall with text length. Numerals are excluded from token counts, since numeric values are not lexical repetition.

De-identification#

Classroom transcripts and student writing carry names and contact details. Run this before anything else touches the data. Local and deterministic — nothing is transmitted.

from mathipy import deidentify_turns, scan, segment_turns

turns = segment_turns("lesson.vtt", transcript_format="vtt")
clean = deidentify_turns(turns)
# speaker labels become "Speaker 1", "Speaker 2"; names in text become [NAME]

scan(text)   # report what is present without changing anything

Masks emails, phone numbers, URLs, social security numbers, student IDs, dates, and titled names such as Ms. Rivera. deidentify_turns also removes speaker names from the body text, since speakers address each other by name. Pass names=[...] to remove additional names, and keep=["url"] to retain a category.

Matching is pattern-based, so it will not catch every name. Review scan output before treating a transcript as clean.

Dialogue#

Splits a transcript into speaker turns and reports turn-level measures. Runs locally with no network call, so transcripts are never transmitted.

from mathipy import segment_turns, turn_measures, check_speakers

turns = segment_turns("lesson.vtt", transcript_format="vtt")
turn_measures(turns)
# {'turns': 42, 'speakers': 3, 'talk_share': {'Teacher': 0.61, ...},
#  'mean_turn_words': 14.3, 'uptake_mean': 0.18}

Formats: plain for Speaker: text lines, vtt and srt for captions, csv with speaker_key and text_key. uptake_mean is the lexical overlap between adjacent turns by different speakers.

Pass text= to segment an in-memory string, with separator for turns that are not newline-delimited and strip_pattern to drop inline annotations:

turns = segment_turns(
    text=row["conversation"],
    separator="|EOM|",
    strip_pattern=r"\((?:generic|focus|probing|telling)\)",
)

check_speakers flags labels likely to be transcription artifacts — speakers appearing once, near-duplicate spellings, and runs of consecutive same-speaker turns.

Readability formulas assume written prose with sentence boundaries and are not valid on transcripts. Use the math-content and dialogue measures instead.

Validation#

Automated labels need checking against human coders. This module draws the sample, emits the coding instrument, and scores agreement. Standard library only.

from mathipy import (
    stratified_sample, write_coding_sheets, write_label_studio,
    score_agreement, disagreements, ocr_rubric, visual_rubric,
)

sample = stratified_sample(records, ["tier", "band"], n=120, seed=2026)
write_coding_sheets(sample, ocr_rubric, "output/validation", carry=["item_id", "image", "text"])
write_label_studio(sample, visual_rubric, "output/validation")

stratified_sample allocates proportionally with a floor per stratum, so rare categories are not lost. write_coding_sheets writes one blank sheet per rater plus the rubric. write_label_studio writes a task file and a labeling-interface config for Label Studio, carrying provenance fields into each task.

Score the completed sheets:

score_agreement(rows_a, rows_b, list(ocr_rubric))
# {'hallucinated_content': {'agreement': 0.96, 'kappa': 0.71, 'n': 135}, ...}

disagreements(rows_a, rows_b, list(ocr_rubric))   # rows needing adjudication

ocr_rubric covers extraction completeness and hallucinated content. visual_rubric covers the types and the function labels, drawn from visual_model_definitions so coders and the classifier share one definition.

Data Handling#

MultimodalOCR.extract() and VisualModelClassifier.classify() send image data to the configured provider. Every other analyzer runs locally.

Defaults are https://generativelanguage.googleapis.com/v1beta and https://api.openai.com/v1. Setting base_url redirects to any OpenAI-compatible endpoint, including a local server, in which case nothing is transmitted. Results record provider, model, base_url, and extracted_at.

Provider terms differ by tier and change. As of this release, unpaid Gemini API content may be used to improve Google products and read by human reviewers; paid-tier content is not used for training; EEA, Swiss, and UK users get paid terms on all tiers. Verify at Gemini and OpenAI.

Before sending secure or restricted items: check the governing agreement, confirm which tier the key belongs to, and consider a local endpoint.

The package sends no telemetry, writes no extracted content to disk, downloads no models, and distributes no assessment items.

compute_interrater_reliability#

Agreement and Cohen’s kappa between two raters, over matched rows.

safe_get#

Retrieves a nested value from a dict, returning a default on any miss.

Provenance and Limitations#

Representation types follow Lesh et al. (1987) and Duval (2006); the data-display family follows Friel et al. (2001); domain and grade-band columns map to CCSSM (NGA & CCSSO, 2010). The pattern_visual, picture, and other rows are author-derived.

The three function labels adapt Levin et al. (1987), retaining decorative and representational and collapsing interpretational and transformational into essential, because assessment items call for a solve-relevance judgment. This is an adaptation, not an implementation of the five-category scheme.

Classification is not deterministic: temperature is 0.1, no seed is available, and hosted models change over time. Record the model identifier and date, cache results rather than re-classifying, and report vote agreement when votes > 1.

Classifier accuracy against human coders has not been established. Treat labels as provisional and validate against hand-coded items before relying on them for inference.

References#

  • Carney, R. N., & Levin, J. R. (2002). Pictorial illustrations still improve students’ learning from text. Educational Psychology Review, 14(1), 5-26. https://doi.org/10.1023/A:1013176309260

  • Duval, R. (2006). A cognitive analysis of problems of comprehension in a learning of mathematics. Educational Studies in Mathematics, 61(1-2), 103-131. https://doi.org/10.1007/s10649-006-0400-z

  • Friel, S. N., Curcio, F. R., & Bright, G. W. (2001). Making sense of graphs. Journal for Research in Mathematics Education, 32(2), 124-158.

  • Lesh, R., Post, T., & Behr, M. (1987). Representations and translations among representations in mathematics learning and problem solving. In C. Janvier (Ed.), Problems of representation in the teaching and learning of mathematics (pp. 33-40). Erlbaum.

  • Levin, J. R., Anglin, G. J., & Carney, R. N. (1987). On empirically validating functions of pictures in prose. In D. M. Willows & H. A. Houghton (Eds.), The psychology of illustration (Vol. 1, pp. 51-85). Springer.

  • National Governors Association Center for Best Practices & Council of Chief State School Officers. (2010). Common Core State Standards for Mathematics.

Sample Data#

No assessment items are distributed with mathipy. These helpers resolve paths inside a directory supplied by the user — set MATHIPY_DATA_DIR to a folder holding item images and a metadata CSV.

export MATHIPY_DATA_DIR=/path/to/items
from mathipy.data import data_directory, get_sample_csv, get_sample_image, list_sample_images

print(data_directory())
images = list_sample_images()          # [] when the directory holds no PNGs
csv_path = get_sample_csv()            # path only; may not exist
image_path = get_sample_image("demo-g4-algebra")

The functions return paths without requiring the files to exist, so check Path.exists() before reading. get_sample_image rejects item IDs containing path separators or characters outside A-Z a-z 0-9 _ - space #.

Mathematics Register#

Existing item features count how much text an item carries. These describe what the language does: which relation the wording encodes, which kind of number it names, and whether an everyday word is being used in its mathematical sense.

Depends only on the standard library.

register_features#

from mathipy import register_features

register_features("Ann has 5 less than Ben. The table shows 1/2.")
# {'rel_comparison': 1, 'rel_order_reversed': 1, 'rel_partitive': 1, ...
#  'num_fraction': 1, 'homonym_count': 1, 'homonym_unique': 1}

Returns every measure below in one dictionary.

relational_features#

Counts of the relations the wording encodes: comparison, rate, multiplicative, partitive, distribution, and division.

rel_order_reversed counts the marked comparative, in which a quantity precedes the comparative as in five less than Ben. The operands then appear in the reverse of the order the operation needs, which is a documented source of error (Lewis & Mayer, 1987).

from mathipy import relational_features

relational_features("Ann has 5 less than Ben.")
# {'rel_comparison': 1, 'rel_order_reversed': 1, 'rel_total': 1, ...}

number_features#

Numbers split by what they do: count a set, index a position, name a part, or identify an object. Treating every number as a count misdescribes ordinary text (Woodin & Winter, 2024), and the kinds make different demands on a reader. num_round counts values that signal approximation.

from mathipy import number_features

number_features("The 3rd bus carries 40 people and 1/2 of the seats are free.")
# {'num_cardinal': 1, 'num_ordinal': 1, 'num_fraction': 1, 'num_round': 1, ...}

homonym_features#

Everyday words carrying a distinct mathematical sense, such as table, mean, product, power, base, root, plane, right, and odd. A reader must choose between the two senses, which is one of the demands the register places on comprehension (Schleppegrell, 2007).

from mathipy import homonym_features

homonym_features("The table shows the mean and the range.")
# {'homonym_count': 3, 'homonym_unique': 3}