Skip to content

API reference

scholia.scheme

Marking scheme: questions and their scored categories.

Category dataclass

A single scored outcome for one question.

Source code in src/scholia/scheme.py
@dataclass
class Category:
    """A single scored outcome for one question."""

    marks: int
    feedback: str

Question dataclass

A question in the marking scheme, with its possible categories.

Source code in src/scholia/scheme.py
@dataclass
class Question:
    """A question in the marking scheme, with its possible categories."""

    categories: dict[str, Category] = field(default_factory=dict)

Scheme dataclass

The full marking scheme for one piece of assessment.

Source code in src/scholia/scheme.py
@dataclass
class Scheme:
    """The full marking scheme for one piece of assessment."""

    questions: dict[str, Question] = field(default_factory=dict)

    @classmethod
    def load(cls, path: Path) -> Scheme:
        """Load a scheme from a YAML file."""
        with open(path) as file_handle:
            raw: dict[str, Any] = yaml.safe_load(file_handle) or {}
        questions: dict[str, Question] = {}
        for question_name, categories_data in raw.items():
            categories: dict[str, Category] = {}
            for category_id, category_data in (categories_data or {}).items():
                categories[category_id] = Category(
                    marks=category_data["marks"],
                    feedback=category_data["feedback"],
                )
            questions[question_name] = Question(categories=categories)
        return cls(questions=questions)

    def save(self, path: Path) -> None:
        """Write the scheme to a YAML file."""
        raw: dict[str, Any] = {}
        for question_name, question in self.questions.items():
            raw[question_name] = {}
            for category_id, category in question.categories.items():
                raw[question_name][category_id] = {
                    "marks": category.marks,
                    "feedback": category.feedback,
                }
        with open(path, "w") as file_handle:
            yaml.dump(
                raw,
                file_handle,
                allow_unicode=True,
                default_flow_style=False,
                sort_keys=False,
            )

    def question_names(self) -> list[str]:
        """Return question names in definition order."""
        return list(self.questions.keys())

    def get_category(self, question_name: str, category_id: str) -> Category | None:
        """Return the category, or ``None`` if not found."""
        question = self.questions.get(question_name)
        if question is None:
            return None
        return question.categories.get(category_id)

get_category(question_name, category_id)

Return the category, or None if not found.

Source code in src/scholia/scheme.py
def get_category(self, question_name: str, category_id: str) -> Category | None:
    """Return the category, or ``None`` if not found."""
    question = self.questions.get(question_name)
    if question is None:
        return None
    return question.categories.get(category_id)

load(path) classmethod

Load a scheme from a YAML file.

Source code in src/scholia/scheme.py
@classmethod
def load(cls, path: Path) -> Scheme:
    """Load a scheme from a YAML file."""
    with open(path) as file_handle:
        raw: dict[str, Any] = yaml.safe_load(file_handle) or {}
    questions: dict[str, Question] = {}
    for question_name, categories_data in raw.items():
        categories: dict[str, Category] = {}
        for category_id, category_data in (categories_data or {}).items():
            categories[category_id] = Category(
                marks=category_data["marks"],
                feedback=category_data["feedback"],
            )
        questions[question_name] = Question(categories=categories)
    return cls(questions=questions)

question_names()

Return question names in definition order.

Source code in src/scholia/scheme.py
def question_names(self) -> list[str]:
    """Return question names in definition order."""
    return list(self.questions.keys())

save(path)

Write the scheme to a YAML file.

Source code in src/scholia/scheme.py
def save(self, path: Path) -> None:
    """Write the scheme to a YAML file."""
    raw: dict[str, Any] = {}
    for question_name, question in self.questions.items():
        raw[question_name] = {}
        for category_id, category in question.categories.items():
            raw[question_name][category_id] = {
                "marks": category.marks,
                "feedback": category.feedback,
            }
    with open(path, "w") as file_handle:
        yaml.dump(
            raw,
            file_handle,
            allow_unicode=True,
            default_flow_style=False,
            sort_keys=False,
        )

scholia.students

Student roster and per-student category assignments.

Student dataclass

A single student and their category assignments.

Source code in src/scholia/students.py
@dataclass
class Student:
    """A single student and their category assignments."""

    student_id: str
    assignments: dict[str, str] = field(default_factory=dict)

Students dataclass

The full student roster.

Source code in src/scholia/students.py
@dataclass
class Students:
    """The full student roster."""

    students: list[Student] = field(default_factory=list)
    question_names: list[str] = field(default_factory=list)

    @classmethod
    def load(cls, path: Path) -> Students:
        """Load the roster from a CSV file."""
        with open(path, newline="") as file_handle:
            reader = csv.DictReader(file_handle)
            fieldnames = list(reader.fieldnames or [])
            question_names = [f for f in fieldnames if f != "student_id"]
            students: list[Student] = []
            for row in reader:
                assignments = {
                    question: row.get(question, "") for question in question_names
                }
                students.append(
                    Student(
                        student_id=row["student_id"],
                        assignments=assignments,
                    )
                )
        return cls(students=students, question_names=question_names)

    def save(self, path: Path) -> None:
        """Write the roster to a CSV file."""
        fieldnames = ["student_id"] + self.question_names
        with open(path, "w", newline="") as file_handle:
            writer = csv.DictWriter(file_handle, fieldnames=fieldnames)
            writer.writeheader()
            for student in self.students:
                row: dict[str, str] = {"student_id": student.student_id}
                for question in self.question_names:
                    row[question] = student.assignments.get(question, "")
                writer.writerow(row)

    def sync_headers(self, question_names: list[str]) -> None:
        """Sync question columns with the given list.

        Questions not yet in the roster are added with empty values.
        The column order is updated to match ``question_names``; columns
        already present but absent from ``question_names`` are appended
        at the end.
        """
        extra = [q for q in self.question_names if q not in question_names]
        new_order = list(question_names) + extra
        for student in self.students:
            for question in new_order:
                if question not in student.assignments:
                    student.assignments[question] = ""
        self.question_names = new_order

    def update(self, student_id: str, question: str, category: str) -> None:
        """Set a student's category for one question.

        If ``question`` is not yet a column in the roster, it is added.

        Raises:
            ValueError: If ``student_id`` is not in the roster.
        """
        for student in self.students:
            if student.student_id == student_id:
                if question not in self.question_names:
                    self.question_names.append(question)
                student.assignments[question] = category
                return
        raise ValueError(f"Student {student_id!r} not found")

    def get_student(self, student_id: str) -> Student | None:
        """Return the student with the given ID, or ``None``."""
        for student in self.students:
            if student.student_id == student_id:
                return student
        return None

get_student(student_id)

Return the student with the given ID, or None.

Source code in src/scholia/students.py
def get_student(self, student_id: str) -> Student | None:
    """Return the student with the given ID, or ``None``."""
    for student in self.students:
        if student.student_id == student_id:
            return student
    return None

load(path) classmethod

Load the roster from a CSV file.

Source code in src/scholia/students.py
@classmethod
def load(cls, path: Path) -> Students:
    """Load the roster from a CSV file."""
    with open(path, newline="") as file_handle:
        reader = csv.DictReader(file_handle)
        fieldnames = list(reader.fieldnames or [])
        question_names = [f for f in fieldnames if f != "student_id"]
        students: list[Student] = []
        for row in reader:
            assignments = {
                question: row.get(question, "") for question in question_names
            }
            students.append(
                Student(
                    student_id=row["student_id"],
                    assignments=assignments,
                )
            )
    return cls(students=students, question_names=question_names)

save(path)

Write the roster to a CSV file.

Source code in src/scholia/students.py
def save(self, path: Path) -> None:
    """Write the roster to a CSV file."""
    fieldnames = ["student_id"] + self.question_names
    with open(path, "w", newline="") as file_handle:
        writer = csv.DictWriter(file_handle, fieldnames=fieldnames)
        writer.writeheader()
        for student in self.students:
            row: dict[str, str] = {"student_id": student.student_id}
            for question in self.question_names:
                row[question] = student.assignments.get(question, "")
            writer.writerow(row)

sync_headers(question_names)

Sync question columns with the given list.

Questions not yet in the roster are added with empty values. The column order is updated to match question_names; columns already present but absent from question_names are appended at the end.

Source code in src/scholia/students.py
def sync_headers(self, question_names: list[str]) -> None:
    """Sync question columns with the given list.

    Questions not yet in the roster are added with empty values.
    The column order is updated to match ``question_names``; columns
    already present but absent from ``question_names`` are appended
    at the end.
    """
    extra = [q for q in self.question_names if q not in question_names]
    new_order = list(question_names) + extra
    for student in self.students:
        for question in new_order:
            if question not in student.assignments:
                student.assignments[question] = ""
    self.question_names = new_order

update(student_id, question, category)

Set a student's category for one question.

If question is not yet a column in the roster, it is added.

Raises:

Type Description
ValueError

If student_id is not in the roster.

Source code in src/scholia/students.py
def update(self, student_id: str, question: str, category: str) -> None:
    """Set a student's category for one question.

    If ``question`` is not yet a column in the roster, it is added.

    Raises:
        ValueError: If ``student_id`` is not in the roster.
    """
    for student in self.students:
        if student.student_id == student_id:
            if question not in self.question_names:
                self.question_names.append(question)
            student.assignments[question] = category
            return
    raise ValueError(f"Student {student_id!r} not found")

scholia.marks

Compute numeric marks from category assignments and a scheme.

compute_question_marks(student, scheme)

Return each question's mark for a student.

Returns None for any question with no valid category assigned.

Source code in src/scholia/marks.py
def compute_question_marks(student: Student, scheme: Scheme) -> dict[str, int | None]:
    """Return each question's mark for a student.

    Returns ``None`` for any question with no valid category assigned.
    """
    result: dict[str, int | None] = {}
    for question_name in scheme.question_names():
        category_id = student.assignments.get(question_name, "")
        category = scheme.get_category(question_name, category_id)
        result[question_name] = category.marks if category is not None else None
    return result

compute_total_marks(student, scheme)

Return the sum of all question marks for a student.

Returns None if any question is unassigned or the scheme is empty.

Source code in src/scholia/marks.py
def compute_total_marks(student: Student, scheme: Scheme) -> int | None:
    """Return the sum of all question marks for a student.

    Returns ``None`` if any question is unassigned or the scheme is empty.
    """
    question_marks = compute_question_marks(student, scheme)
    values = list(question_marks.values())
    if not values or any(value is None for value in values):
        return None
    return sum(value for value in values if value is not None)

scholia.feedback

Generate per-student feedback markdown files.

generate_all_feedback(students, scheme, feedback_dir)

Write feedback files for all students in the roster.

Source code in src/scholia/feedback.py
def generate_all_feedback(
    students: Students,
    scheme: Scheme,
    feedback_dir: Path,
) -> None:
    """Write feedback files for all students in the roster."""
    for student in students.students:
        generate_student_feedback(student, scheme, feedback_dir)

generate_student_feedback(student, scheme, feedback_dir)

Write a markdown feedback file to feedback_dir/<student_id>.md.

Source code in src/scholia/feedback.py
def generate_student_feedback(
    student: Student,
    scheme: Scheme,
    feedback_dir: Path,
) -> None:
    """Write a markdown feedback file to ``feedback_dir/<student_id>.md``."""
    feedback_dir.mkdir(parents=True, exist_ok=True)
    question_marks = compute_question_marks(student, scheme)
    total = compute_total_marks(student, scheme)

    lines: list[str] = [f"# Feedback: {student.student_id}", ""]

    if total is not None:
        lines += [f"**Total marks:** {total}", ""]
    else:
        lines += ["**Total marks:** incomplete", ""]

    lines += ["## Question breakdown", ""]

    for question_name, marks in question_marks.items():
        category_id = student.assignments.get(question_name, "")
        category = scheme.get_category(question_name, category_id)
        lines += [f"### {question_name}", ""]
        if marks is not None and category is not None:
            lines += [f"**Marks:** {marks}", "", category.feedback, ""]
        else:
            lines += ["**Marks:** not yet marked", ""]

    (feedback_dir / f"{student.student_id}.md").write_text("\n".join(lines))

scholia.stats

Generate summary statistics and charts for a marked cohort.

generate_marks_csv(students, scheme, output_path)

Write student IDs and total marks to a CSV file.

Students with incomplete marking have an empty total_marks cell.

Source code in src/scholia/stats.py
def generate_marks_csv(
    students: Students,
    scheme: Scheme,
    output_path: Path,
) -> None:
    """Write student IDs and total marks to a CSV file.

    Students with incomplete marking have an empty ``total_marks`` cell.
    """
    with open(output_path, "w", newline="") as file_handle:
        writer = csv.writer(file_handle)
        writer.writerow(["student_id", "total_marks"])
        for student in students.students:
            total = compute_total_marks(student, scheme)
            writer.writerow([student.student_id, "" if total is None else total])

generate_summary(students, scheme, output_path, charts_dir=None)

Write a cohort summary to output_path.

When charts_dir is provided, saves a distribution histogram, cumulative distribution, per-question boxplot, and (with at least two questions and two complete students) a correlation heatmap.

Source code in src/scholia/stats.py
def generate_summary(
    students: Students,
    scheme: Scheme,
    output_path: Path,
    charts_dir: Path | None = None,
) -> None:
    """Write a cohort summary to ``output_path``.

    When ``charts_dir`` is provided, saves a distribution histogram,
    cumulative distribution, per-question boxplot, and (with at least two
    questions and two complete students) a correlation heatmap.
    """
    scheme_question_names = scheme.question_names()
    all_totals: list[int] = []
    per_question_categories: dict[str, dict[str, int]] = {
        q: {} for q in scheme_question_names
    }
    per_question_mark_values: dict[str, list[int]] = {
        q: [] for q in scheme_question_names
    }
    complete_mark_rows: list[list[int]] = []

    for student in students.students:
        total = compute_total_marks(student, scheme)
        question_marks = compute_question_marks(student, scheme)

        if total is not None:
            all_totals.append(total)
            complete_mark_rows.append(
                [
                    m
                    for q in scheme_question_names
                    if (m := question_marks[q]) is not None
                ]
            )

        for question_name in scheme_question_names:
            category_id = student.assignments.get(question_name, "")
            if category_id:
                counter = per_question_categories[question_name]
                counter[category_id] = counter.get(category_id, 0) + 1
            marks = question_marks.get(question_name)
            if marks is not None:
                per_question_mark_values[question_name].append(marks)

    lines: list[str] = ["# Marking summary", ""]

    if all_totals:
        mean = statistics.mean(all_totals)
        median = statistics.median(all_totals)
        std = statistics.stdev(all_totals) if len(all_totals) > 1 else 0.0
        lines += [
            f"**Students marked:** {len(all_totals)}",
            "",
            f"**Mean:** {mean:.2f}",
            "",
            f"**Median:** {median:.2f}",
            "",
            f"**Standard deviation:** {std:.2f}",
            "",
            f"**Min:** {min(all_totals)}",
            "",
            f"**Max:** {max(all_totals)}",
            "",
        ]
        if charts_dir is not None:
            lines += _generate_charts(
                all_totals,
                per_question_mark_values,
                scheme_question_names,
                complete_mark_rows,
                charts_dir,
            )
    else:
        lines += ["No complete marks yet.", ""]

    lines += ["## Per-question breakdown", ""]

    for question_name, category_counts in per_question_categories.items():
        question = scheme.questions[question_name]
        lines += [f"### {question_name}", ""]
        for category_id, category in question.categories.items():
            count = category_counts.get(category_id, 0)
            label = category.feedback.removesuffix(".")
            lines.append(f"- {label}: {count} student(s) ({category.marks} marks)")
        lines.append("")

    output_path.write_text("\n".join(lines))