Skip to content

Document Packet Splitting Evaluation

Stickler provides two complementary metric sets for evaluating document packet splitting — the task of separating a multi-page document packet into individual documents, classifying them, and ordering their pages.

Both metric sets are based on the DocSplit paper.

When to Use Which

Metric Set Best For
Classical (DocSplitClassificationMetrics) Quick pass/fail evaluation, section-level ground truth
Proposed (evaluate_packet) Nuanced evaluation with partial credit, page-level ground truth

The classical metrics are binary (exact match or miss). The proposed metrics provide continuous scores that distinguish between near-correct and completely wrong predictions.


Classical Metrics

What They Measure

Metric Description
Page Level Accuracy What fraction of pages have the correct document class?
Split Accuracy (Without Order) What fraction of GT sections have a predicted section with the same page set and class?
Split Accuracy (With Order) Same as above, but page order must also match exactly.

Input Format

load_sections() takes two lists of section dicts — one for ground truth, one for predictions.

Each section dict represents one document within the packet:

{
    "section_id": "s1",           # Unique ID (optional, auto-generated if missing)
    "document_class": "invoice",  # Document type label
    "page_indices": [0, 1, 2]    # Zero-based page indices in this document
}

The document_class field also accepts a dict: {"type": "invoice"}.

The page_indices field can also be nested: {"split_document": {"page_indices": [0, 1, 2]}}.

Rules:

  • page_indices are zero-based integers
  • Pages can be non-sequential (e.g., [0, 2, 4] for interleaved documents)
  • A page index should appear in exactly one section per list
  • GT and predicted lists don't need the same number of sections
  • section_id values don't need to match between GT and pred — matching is by page set + class

Example

from stickler.doc_split import DocSplitClassificationMetrics

gt = [
    {"section_id": "s1", "document_class": "invoice", "page_indices": [0, 1, 2]},
    {"section_id": "s2", "document_class": "form", "page_indices": [3, 4]},
]

pred = [
    {"section_id": "p1", "document_class": "invoice", "page_indices": [0, 1, 2]},
    {"section_id": "p2", "document_class": "form", "page_indices": [3, 4]},
]

m = DocSplitClassificationMetrics()
m.load_sections(gt, pred)
results = m.calculate_all_metrics()

print(results["page_level_accuracy"]["accuracy"])           # 1.0
print(results["split_accuracy_without_order"]["accuracy"])   # 1.0
print(results["split_accuracy_with_order"]["accuracy"])      # 1.0

# Generate a markdown report
report = m.generate_markdown_report(results)

Proposed Metrics (Packet Score)

What They Measure

Metric Range Description
V-measure [0, 1] Are predicted clusters homogeneous and complete?
Rand Index [0, 1] Pairwise clustering agreement
Clustering Score [0, 1] w × V-measure + (1-w) × Rand Index
Kendall's Tau [-1, 1] Page ordering correlation per document
Ordering Score [-1, 1] Mean Tau across multi-page documents
Packet Score [-0.5, 1] α × Clustering + β × Ordering

Why These Are Better Than Classical

The paper demonstrates several failure modes of classical metrics:

  • No partial credit: A single boundary error cascades to 0% on all affected sections
  • No severity distinction: Over-segmentation and under-segmentation score identically
  • No ordering direction: Reversed pages and scrambled pages score the same

The proposed metrics address all of these with continuous scoring.

Input Format

evaluate_packet() accepts page-level data in three formats:

  1. List[Dict] — list of page dicts (recommended)
  2. str — path to a CSV file
  3. pd.DataFrame — pandas DataFrame

Each row represents one page in the packet:

{
    "group_id": "inv-01",                # GT document group
    "group_id_predicted": "inv-01",      # Predicted document group
    "page_number": 1,                    # GT page order within document
    "page_number_predicted": 1,          # Predicted page order
    "class_label": "invoice",            # GT document type (strict mode only)
    "class_label_predicted": "invoice",  # Predicted document type (strict mode only)
}

Required columns (always):

Column Type Description
group_id str/int Ground truth document group
group_id_predicted str/int Predicted document group
page_number int GT page order within its document
page_number_predicted int Predicted page order

Additional columns (strict mode):

Column Type Description
class_label str GT document type
class_label_predicted str Predicted document type

Rules:

  • One row per page in the packet
  • group_id values don't need to match group_id_predicted — clustering metrics compare partition structure, not labels
  • page_number represents page ordering within a document. Both within-document numbering (1, 2, 3 per document) and global-sequential numbering (e.g., form-01 uses 4, 5 if it starts at the 4th page) are valid — Kendall's Tau only compares relative ordering
  • Single-page groups receive a perfect ordering score of 1.0 (trivially correct order)
  • strict_clustering=True penalizes misclassification by breaking clustering credit for misclassified pages

Example

from stickler.doc_split import evaluate_packet

pages = [
    {"group_id": "inv-01", "group_id_predicted": "inv-01",
     "page_number": 1, "page_number_predicted": 1,
     "class_label": "invoice", "class_label_predicted": "invoice"},
    {"group_id": "inv-01", "group_id_predicted": "inv-01",
     "page_number": 2, "page_number_predicted": 2,
     "class_label": "invoice", "class_label_predicted": "invoice"},
    {"group_id": "inv-01", "group_id_predicted": "inv-01",
     "page_number": 3, "page_number_predicted": 3,
     "class_label": "invoice", "class_label_predicted": "invoice"},
    {"group_id": "form-01", "group_id_predicted": "form-01",
     "page_number": 4, "page_number_predicted": 4,
     "class_label": "form", "class_label_predicted": "form"},
    {"group_id": "form-01", "group_id_predicted": "form-01",
     "page_number": 5, "page_number_predicted": 5,
     "class_label": "form", "class_label_predicted": "form"},
]

results = evaluate_packet(pages, strict_clustering=True)

print(f"Packet Score:    {results['final_score']:.4f}")
print(f"Clustering:      {results['clustering_score']:.4f}")
print(f"V-measure:       {results['v_measure']:.4f}")
print(f"Rand Index:      {results['rand_index']:.4f}")
print(f"Ordering:        {results['avg_ordering_score']:.4f}")

From CSV

evaluate_packet() can read directly from a CSV file. The CSV must have a header row with the column names listed above.

group_id,group_id_predicted,page_number,page_number_predicted,class_label,class_label_predicted
inv-01,inv-01,1,1,invoice,invoice
inv-01,inv-01,2,2,invoice,invoice
inv-01,inv-01,3,3,invoice,invoice
form-01,form-01,4,4,form,form
form-01,form-01,5,5,form,form
results = evaluate_packet("path/to/evaluation_data.csv", strict_clustering=True)

The class_label and class_label_predicted columns can be omitted from the CSV if you're not using strict_clustering=True.

From pandas DataFrame

If you already have your data in a DataFrame (common when working with evaluation pipelines), pass it directly:

import pandas as pd
from stickler.doc_split import evaluate_packet

df = pd.read_csv("evaluation_data.csv")

# The DataFrame must have these columns:
#   group_id, group_id_predicted, page_number, page_number_predicted
# And optionally (for strict_clustering=True):
#   class_label, class_label_predicted

results = evaluate_packet(df, strict_clustering=True)

The DataFrame column names must match exactly — they are case-sensitive. The column types are flexible: group_id and group_id_predicted can be strings or integers, and page_number / page_number_predicted can be any numeric type.

Here's the full column reference for the DataFrame:

Column Required Type Description
group_id Always str or int Ground truth document group identifier. Pages with the same group_id belong to the same document.
group_id_predicted Always str or int Model's predicted document group. Does not need to use the same labels as group_id — only the partition structure matters.
page_number Always int Ground truth page position within its document. Used for ordering evaluation via Kendall's Tau.
page_number_predicted Always int Model's predicted page position within its document.
class_label Strict mode str Ground truth document type (e.g., "invoice", "form", "letter").
class_label_predicted Strict mode str Model's predicted document type.

Tuning Weights

# Emphasize clustering over ordering
results = evaluate_packet(pages, alpha=0.7, beta=0.3)

# Emphasize V-measure over Rand Index in clustering
results = evaluate_packet(pages, v_measure_weight=0.8)

API Reference

stickler.doc_split.DocSplitClassificationMetrics

Calculator for document split classification accuracy metrics.

Evaluates three types of accuracy: 1. Page Level Accuracy: Classification accuracy for individual pages 2. Split Accuracy (Without Order): Correct page grouping regardless of order 3. Split Accuracy (With Order): Correct page grouping with exact order

Source code in stickler/doc_split/doc_split_classification_metrics.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
class DocSplitClassificationMetrics:
    """
    Calculator for document split classification accuracy metrics.

    Evaluates three types of accuracy:
    1. Page Level Accuracy: Classification accuracy for individual pages
    2. Split Accuracy (Without Order): Correct page grouping regardless of order
    3. Split Accuracy (With Order): Correct page grouping with exact order
    """

    def __init__(self):
        """Initialize the metrics calculator."""
        self.page_classifications_gt: Dict[int, str] = {}  # page_index -> class
        self.page_classifications_pred: Dict[int, str] = {}  # page_index -> class
        self.sections_gt: List[Dict[str, Any]] = []
        self.sections_pred: List[Dict[str, Any]] = []
        self.errors: List[str] = []

    def _get_document_class(self, section_data: Dict[str, Any]) -> str:
        """
        Safely extract document class from section data.

        Args:
            section_data: Section data dictionary

        Returns:
            Document class name or "Unknown" if missing/invalid
        """
        if not section_data:
            return "Unknown"

        doc_class = section_data.get("document_class")
        if not doc_class or doc_class is None:
            return "Unknown"

        if isinstance(doc_class, dict):
            return doc_class.get("type", "Unknown")

        return str(doc_class) if doc_class else "Unknown"

    def _get_page_indices(self, section_data: Dict[str, Any]) -> List[int]:
        """
        Safely extract page indices from section data.

        Args:
            section_data: Section data dictionary

        Returns:
            List of page indices (0-based, may be non-sequential) or empty list
        """
        if not section_data:
            return []

        # Support both flat format (page_indices at top level)
        # and nested format (page_indices under split_document)
        page_indices = section_data.get("page_indices")
        if page_indices is None:
            split_doc = section_data.get("split_document")
            if split_doc and isinstance(split_doc, dict):
                page_indices = split_doc.get("page_indices", [])

        if not page_indices or page_indices is None:
            return []

        # Ensure all indices are integers
        try:
            return [int(idx) for idx in page_indices]
        except (ValueError, TypeError) as e:
            logger.warning(f"Invalid page indices format: {e}")
            return []

    def load_sections(
        self,
        ground_truth_sections: List[Dict[str, Any]],
        predicted_sections: List[Dict[str, Any]],
    ) -> None:
        """
        Load and parse ground truth and predicted sections.

        Each section dict should have:
            - section_id: str identifier
            - document_class: str class name (or dict with "type" key)
            - page_indices: list of int page indices (0-based)

        Alternatively, the accelerator nested format is also supported:
            - section_id: str
            - document_class: {"type": "invoice"}
            - split_document: {"page_indices": [0, 1, 2]}

        Args:
            ground_truth_sections: List of section dicts with ground truth data
            predicted_sections: List of section dicts with predicted data
        """
        # Load ground truth sections
        for section in ground_truth_sections:
            section_id = section.get("section_id", f"gt_{len(self.sections_gt)}")
            doc_class = self._get_document_class(section)
            page_indices = self._get_page_indices(section)

            if not page_indices:
                logger.warning(
                    f"Section {section_id} has no page indices in ground truth"
                )

            self.sections_gt.append(
                {
                    "section_id": section_id,
                    "document_class": doc_class,
                    "page_indices": page_indices,
                }
            )

            for page_idx in page_indices:
                self.page_classifications_gt[page_idx] = doc_class

        # Load predicted sections
        for section in predicted_sections:
            section_id = section.get("section_id", f"pred_{len(self.sections_pred)}")
            doc_class = self._get_document_class(section)
            page_indices = self._get_page_indices(section)

            if not page_indices:
                logger.warning(
                    f"Section {section_id} has no page indices in prediction"
                )

            self.sections_pred.append(
                {
                    "section_id": section_id,
                    "document_class": doc_class,
                    "page_indices": page_indices,
                }
            )

            for page_idx in page_indices:
                self.page_classifications_pred[page_idx] = doc_class

        logger.info(
            f"Loaded {len(self.sections_gt)} ground truth sections "
            f"and {len(self.sections_pred)} predicted sections"
        )

    def calculate_page_level_accuracy(self) -> Dict[str, Any]:
        """
        Calculate page-level classification accuracy.

        Compares document_class for each page index individually, regardless of
        which section it appears in.

        Returns:
            Dictionary with page-level accuracy metrics and per-page details
        """
        all_pages = set(self.page_classifications_gt.keys()) | set(
            self.page_classifications_pred.keys()
        )

        if not all_pages:
            return {
                "accuracy": 0.0,
                "total_pages": 0,
                "correct_pages": 0,
                "page_details": [],
            }

        correct_count = 0
        page_details = []

        for page_idx in sorted(all_pages):
            gt_class = self.page_classifications_gt.get(page_idx, "Missing")
            pred_class = self.page_classifications_pred.get(page_idx, "Missing")

            is_correct = gt_class == pred_class
            if is_correct:
                correct_count += 1

            page_details.append(
                {
                    "page_index": page_idx,
                    "ground_truth_class": gt_class,
                    "predicted_class": pred_class,
                    "correct": is_correct,
                }
            )

        accuracy = correct_count / len(all_pages) if all_pages else 0.0

        return {
            "accuracy": accuracy,
            "total_pages": len(all_pages),
            "correct_pages": correct_count,
            "page_details": page_details,
        }

    def calculate_split_accuracy_without_order(self) -> Dict[str, Any]:
        """
        Calculate document split accuracy without considering page order.

        For each ground truth section, checks if there's a predicted section with:
        - Same set of page indices (regardless of order)
        - Same document_class

        Returns:
            Dictionary with split accuracy metrics and per-section details
        """
        if not self.sections_gt:
            return {
                "accuracy": 0.0,
                "total_sections": 0,
                "correct_sections": 0,
                "section_details": [],
            }

        correct_count = 0
        section_details = []

        for gt_section in self.sections_gt:
            gt_pages_set = set(gt_section["page_indices"])
            gt_class = gt_section["document_class"]
            section_id = gt_section["section_id"]

            # Find if any predicted section matches (same pages set + same class)
            matched = False
            matched_pred_section = None

            for pred_section in self.sections_pred:
                pred_pages_set = set(pred_section["page_indices"])
                pred_class = pred_section["document_class"]

                if gt_pages_set == pred_pages_set and gt_class == pred_class:
                    matched = True
                    matched_pred_section = pred_section
                    break

            if matched:
                correct_count += 1

            section_details.append(
                {
                    "section_id": section_id,
                    "ground_truth_class": gt_class,
                    "ground_truth_pages": sorted(gt_section["page_indices"]),
                    "matched": matched,
                    "matched_section_id": (
                        matched_pred_section["section_id"]
                        if matched_pred_section
                        else None
                    ),
                    "predicted_class": (
                        matched_pred_section["document_class"]
                        if matched_pred_section
                        else "No Match"
                    ),
                    "predicted_pages": (
                        sorted(matched_pred_section["page_indices"])
                        if matched_pred_section
                        else []
                    ),
                }
            )

        accuracy = correct_count / len(self.sections_gt) if self.sections_gt else 0.0

        return {
            "accuracy": accuracy,
            "total_sections": len(self.sections_gt),
            "correct_sections": correct_count,
            "section_details": section_details,
        }

    def calculate_split_accuracy_with_order(self) -> Dict[str, Any]:
        """
        Calculate document split accuracy with page order consideration.

        For each ground truth section, checks if there's a predicted section with:
        - Exact same page indices list (same order)
        - Same document_class

        Returns:
            Dictionary with split accuracy metrics and per-section details
        """
        if not self.sections_gt:
            return {
                "accuracy": 0.0,
                "total_sections": 0,
                "correct_sections": 0,
                "section_details": [],
            }

        correct_count = 0
        section_details = []

        for gt_section in self.sections_gt:
            gt_pages = gt_section["page_indices"]
            gt_class = gt_section["document_class"]
            section_id = gt_section["section_id"]

            # Find if any predicted section matches (exact page order + same class)
            matched = False
            matched_pred_section = None
            order_matched = False

            for pred_section in self.sections_pred:
                pred_pages = pred_section["page_indices"]
                pred_class = pred_section["document_class"]

                # Check if pages match as sets first
                pages_match_as_set = set(gt_pages) == set(pred_pages)
                # Check if pages match in order
                pages_match_in_order = gt_pages == pred_pages
                # Check if class matches
                class_matches = gt_class == pred_class

                if pages_match_as_set and class_matches:
                    matched_pred_section = pred_section
                    order_matched = pages_match_in_order

                    if pages_match_in_order:
                        matched = True
                        break

            if matched:
                correct_count += 1

            section_details.append(
                {
                    "section_id": section_id,
                    "ground_truth_class": gt_class,
                    "ground_truth_pages": gt_pages,
                    "matched": matched,
                    "order_matched": order_matched,
                    "matched_section_id": (
                        matched_pred_section["section_id"]
                        if matched_pred_section
                        else None
                    ),
                    "predicted_class": (
                        matched_pred_section["document_class"]
                        if matched_pred_section
                        else "No Match"
                    ),
                    "predicted_pages": (
                        matched_pred_section["page_indices"]
                        if matched_pred_section
                        else []
                    ),
                }
            )

        accuracy = correct_count / len(self.sections_gt) if self.sections_gt else 0.0

        return {
            "accuracy": accuracy,
            "total_sections": len(self.sections_gt),
            "correct_sections": correct_count,
            "section_details": section_details,
        }

    def calculate_all_metrics(self) -> Dict[str, Any]:
        """
        Calculate all document split classification metrics.

        Returns:
            Dictionary containing all three accuracy types and detailed results
        """
        page_level = self.calculate_page_level_accuracy()
        split_without_order = self.calculate_split_accuracy_without_order()
        split_with_order = self.calculate_split_accuracy_with_order()

        return {
            "page_level_accuracy": page_level,
            "split_accuracy_without_order": split_without_order,
            "split_accuracy_with_order": split_with_order,
            "errors": self.errors,
        }

    def generate_markdown_report(self, metrics: Dict[str, Any]) -> str:
        """
        Generate a markdown report for document split classification metrics.

        Args:
            metrics: Dictionary containing all calculated metrics
                (output of calculate_all_metrics)

        Returns:
            Formatted markdown report string
        """
        sections = []

        # Title
        sections.append("# Document Split Classification Evaluation")
        sections.append("")

        # Extract metrics
        page_level = metrics["page_level_accuracy"]
        split_no_order = metrics["split_accuracy_without_order"]
        split_with_order = metrics["split_accuracy_with_order"]

        # Helper function for visual indicator
        def get_indicator(accuracy: float) -> str:
            if accuracy >= 0.9:
                return "🟢"
            elif accuracy >= 0.7:
                return "🟡"
            elif accuracy >= 0.5:
                return "🟠"
            else:
                return "🔴"

        def get_rating(accuracy: float) -> str:
            if accuracy >= 0.9:
                return "🟢 Excellent"
            elif accuracy >= 0.7:
                return "🟡 Good"
            elif accuracy >= 0.5:
                return "🟠 Fair"
            else:
                return "🔴 Poor"

        # Summary section
        sections.append("## 🎯 Split Classification Summary")
        sections.append("")

        page_acc = page_level["accuracy"]
        split_no_ord_acc = split_no_order["accuracy"]
        split_ord_acc = split_with_order["accuracy"]

        # Create progress bars
        page_percent = int(page_acc * 100)
        page_progress = f"[{'█' * (page_percent // 5)}{'░' * (20 - page_percent // 5)}] {page_percent}%"

        split_no_ord_percent = int(split_no_ord_acc * 100)
        split_no_ord_progress = f"[{'█' * (split_no_ord_percent // 5)}{'░' * (20 - split_no_ord_percent // 5)}] {split_no_ord_percent}%"

        split_ord_percent = int(split_ord_acc * 100)
        split_ord_progress = f"[{'█' * (split_ord_percent // 5)}{'░' * (20 - split_ord_percent // 5)}] {split_ord_percent}%"

        sections.append(
            f"- **Page Level Accuracy**: {get_indicator(page_acc)} "
            f"{page_level['correct_pages']}/{page_level['total_pages']} pages "
            f"{page_progress}"
        )
        sections.append(
            f"- **Split Accuracy (Without Order)**: {get_indicator(split_no_ord_acc)} "
            f"{split_no_order['correct_sections']}/{split_no_order['total_sections']} sections "
            f"{split_no_ord_progress}"
        )
        sections.append(
            f"- **Split Accuracy (With Order)**: {get_indicator(split_ord_acc)} "
            f"{split_with_order['correct_sections']}/{split_with_order['total_sections']} sections "
            f"{split_ord_progress}"
        )
        sections.append("")

        # Detailed metrics table
        sections.append("## 📊 Split Classification Metrics")
        sections.append("")
        sections.append("| Metric | Accuracy | Rating | Correct/Total |")
        sections.append("| ------ | :------: | :----: | :-----------: |")
        sections.append(
            f"| Page Level Classification | {page_acc:.4f} | {get_rating(page_acc)} | "
            f"{page_level['correct_pages']}/{page_level['total_pages']} pages |"
        )
        sections.append(
            f"| Document Split (Without Page Order) | {split_no_ord_acc:.4f} | {get_rating(split_no_ord_acc)} | "
            f"{split_no_order['correct_sections']}/{split_no_order['total_sections']} sections |"
        )
        sections.append(
            f"| Document Split (With Page Order) | {split_ord_acc:.4f} | {get_rating(split_ord_acc)} | "
            f"{split_with_order['correct_sections']}/{split_with_order['total_sections']} sections |"
        )
        sections.append("")

        # Combined Section split analysis
        sections.append("## 📑 Section Split Analysis")
        sections.append("")

        if split_no_order["section_details"] or split_with_order["section_details"]:
            sections.append(
                "| Section Match | Page Order Match | Section ID | Expected Class | Expected Pages | Pred Class | Pred Pages | Matched Section |"
            )
            sections.append(
                "| :-----------: | :--------------: | ---------- | -------------- | -------------- | ---------- | ---------- | --------------- |"
            )

            # Track matched predicted section IDs
            matched_pred_section_ids = set()

            for idx, section_with_order in enumerate(
                split_with_order["section_details"]
            ):
                section_no_order = (
                    split_no_order["section_details"][idx]
                    if idx < len(split_no_order["section_details"])
                    else None
                )

                section_match = (
                    "✅" if section_no_order and section_no_order["matched"] else "❌"
                )
                page_order_match = (
                    "✅" if section_with_order.get("order_matched", False) else "❌"
                )

                gt_pages = str(section_with_order["ground_truth_pages"])
                pred_pages = (
                    str(section_with_order["predicted_pages"])
                    if section_with_order["predicted_pages"]
                    else "N/A"
                )
                matched_id = (
                    section_with_order["matched_section_id"]
                    if section_with_order["matched_section_id"]
                    else "N/A"
                )

                if section_with_order["matched_section_id"]:
                    matched_pred_section_ids.add(
                        section_with_order["matched_section_id"]
                    )

                sections.append(
                    f"| {section_match} | {page_order_match} | {section_with_order['section_id']} | "
                    f"{section_with_order['ground_truth_class']} | {gt_pages} | {section_with_order['predicted_class']} | "
                    f"{pred_pages} | {matched_id} |"
                )

            # Add unmatched predicted sections
            for pred_section in self.sections_pred:
                if pred_section["section_id"] not in matched_pred_section_ids:
                    pred_pages = str(pred_section["page_indices"])
                    sections.append(
                        f"| ❌ | ❌ | N/A | No Match | N/A | "
                        f"{pred_section['document_class']} | {pred_pages} | {pred_section['section_id']} |"
                    )
        else:
            sections.append("*No section data available*")

        sections.append("")

        # Errors section if any
        if metrics.get("errors"):
            sections.append("## ⚠️ Errors Encountered")
            sections.append("")
            for error in metrics["errors"]:
                sections.append(f"- {error}")
            sections.append("")

        # Explanation section
        sections.append("## 📖 Metrics Explanation")
        sections.append("")
        sections.append("### Page Level Accuracy")
        sections.append("- Evaluates classification accuracy for **individual pages**")
        sections.append(
            "- Each page index is checked: does its predicted `document_class` match ground truth?"
        )
        sections.append("- Does not consider page grouping into sections")
        sections.append("")
        sections.append("### Section Split Analysis Table")
        sections.append(
            "- Combines all section-level evaluation metrics into a single comprehensive view"
        )
        sections.append(
            "- Shows both ground truth sections and unmatched predicted sections"
        )
        sections.append("")
        sections.append("**Column Definitions:**")
        sections.append("")
        sections.append(
            "- **Section Match**: ✅ if the expected section's pages (as a set) match any predicted section with the same document class, regardless of page order"
        )
        sections.append(
            "- **Page Order Match**: ✅ if Section Match is true AND the page order also matches exactly"
        )
        sections.append(
            "- **Section ID**: Ground truth section identifier (N/A for unmatched predicted sections)"
        )
        sections.append("- **Expected Class**: Document class from ground truth")
        sections.append("- **Expected Pages**: Page indices from ground truth section")
        sections.append("- **Pred Class**: Predicted document class")
        sections.append("- **Pred Pages**: Predicted page indices")
        sections.append(
            "- **Matched Section**: ID of the predicted section that matched the ground truth section"
        )
        sections.append("")
        sections.append("**Unmatched Predicted Sections:**")
        sections.append("")
        sections.append(
            "- Rows with N/A values represent predicted sections that don't correspond to any ground truth section"
        )
        sections.append(
            "- These indicate potential over-segmentation or incorrect classifications"
        )
        sections.append("")
        sections.append("### Document Split Accuracy Metrics")
        sections.append("")
        sections.append(
            "- **Without Page Order**: Counts sections where pages and class match (order doesn't matter)"
        )
        sections.append(
            "- **With Page Order**: Counts sections where pages, class, and page order all match exactly"
        )
        sections.append("")

        sections.append("---")
        sections.append("")

        return "\n".join(sections)

__init__()

Initialize the metrics calculator.

Source code in stickler/doc_split/doc_split_classification_metrics.py
34
35
36
37
38
39
40
def __init__(self):
    """Initialize the metrics calculator."""
    self.page_classifications_gt: Dict[int, str] = {}  # page_index -> class
    self.page_classifications_pred: Dict[int, str] = {}  # page_index -> class
    self.sections_gt: List[Dict[str, Any]] = []
    self.sections_pred: List[Dict[str, Any]] = []
    self.errors: List[str] = []

calculate_all_metrics()

Calculate all document split classification metrics.

Returns:

Type Description
Dict[str, Any]

Dictionary containing all three accuracy types and detailed results

Source code in stickler/doc_split/doc_split_classification_metrics.py
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
def calculate_all_metrics(self) -> Dict[str, Any]:
    """
    Calculate all document split classification metrics.

    Returns:
        Dictionary containing all three accuracy types and detailed results
    """
    page_level = self.calculate_page_level_accuracy()
    split_without_order = self.calculate_split_accuracy_without_order()
    split_with_order = self.calculate_split_accuracy_with_order()

    return {
        "page_level_accuracy": page_level,
        "split_accuracy_without_order": split_without_order,
        "split_accuracy_with_order": split_with_order,
        "errors": self.errors,
    }

calculate_page_level_accuracy()

Calculate page-level classification accuracy.

Compares document_class for each page index individually, regardless of which section it appears in.

Returns:

Type Description
Dict[str, Any]

Dictionary with page-level accuracy metrics and per-page details

Source code in stickler/doc_split/doc_split_classification_metrics.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
def calculate_page_level_accuracy(self) -> Dict[str, Any]:
    """
    Calculate page-level classification accuracy.

    Compares document_class for each page index individually, regardless of
    which section it appears in.

    Returns:
        Dictionary with page-level accuracy metrics and per-page details
    """
    all_pages = set(self.page_classifications_gt.keys()) | set(
        self.page_classifications_pred.keys()
    )

    if not all_pages:
        return {
            "accuracy": 0.0,
            "total_pages": 0,
            "correct_pages": 0,
            "page_details": [],
        }

    correct_count = 0
    page_details = []

    for page_idx in sorted(all_pages):
        gt_class = self.page_classifications_gt.get(page_idx, "Missing")
        pred_class = self.page_classifications_pred.get(page_idx, "Missing")

        is_correct = gt_class == pred_class
        if is_correct:
            correct_count += 1

        page_details.append(
            {
                "page_index": page_idx,
                "ground_truth_class": gt_class,
                "predicted_class": pred_class,
                "correct": is_correct,
            }
        )

    accuracy = correct_count / len(all_pages) if all_pages else 0.0

    return {
        "accuracy": accuracy,
        "total_pages": len(all_pages),
        "correct_pages": correct_count,
        "page_details": page_details,
    }

calculate_split_accuracy_with_order()

Calculate document split accuracy with page order consideration.

For each ground truth section, checks if there's a predicted section with: - Exact same page indices list (same order) - Same document_class

Returns:

Type Description
Dict[str, Any]

Dictionary with split accuracy metrics and per-section details

Source code in stickler/doc_split/doc_split_classification_metrics.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
def calculate_split_accuracy_with_order(self) -> Dict[str, Any]:
    """
    Calculate document split accuracy with page order consideration.

    For each ground truth section, checks if there's a predicted section with:
    - Exact same page indices list (same order)
    - Same document_class

    Returns:
        Dictionary with split accuracy metrics and per-section details
    """
    if not self.sections_gt:
        return {
            "accuracy": 0.0,
            "total_sections": 0,
            "correct_sections": 0,
            "section_details": [],
        }

    correct_count = 0
    section_details = []

    for gt_section in self.sections_gt:
        gt_pages = gt_section["page_indices"]
        gt_class = gt_section["document_class"]
        section_id = gt_section["section_id"]

        # Find if any predicted section matches (exact page order + same class)
        matched = False
        matched_pred_section = None
        order_matched = False

        for pred_section in self.sections_pred:
            pred_pages = pred_section["page_indices"]
            pred_class = pred_section["document_class"]

            # Check if pages match as sets first
            pages_match_as_set = set(gt_pages) == set(pred_pages)
            # Check if pages match in order
            pages_match_in_order = gt_pages == pred_pages
            # Check if class matches
            class_matches = gt_class == pred_class

            if pages_match_as_set and class_matches:
                matched_pred_section = pred_section
                order_matched = pages_match_in_order

                if pages_match_in_order:
                    matched = True
                    break

        if matched:
            correct_count += 1

        section_details.append(
            {
                "section_id": section_id,
                "ground_truth_class": gt_class,
                "ground_truth_pages": gt_pages,
                "matched": matched,
                "order_matched": order_matched,
                "matched_section_id": (
                    matched_pred_section["section_id"]
                    if matched_pred_section
                    else None
                ),
                "predicted_class": (
                    matched_pred_section["document_class"]
                    if matched_pred_section
                    else "No Match"
                ),
                "predicted_pages": (
                    matched_pred_section["page_indices"]
                    if matched_pred_section
                    else []
                ),
            }
        )

    accuracy = correct_count / len(self.sections_gt) if self.sections_gt else 0.0

    return {
        "accuracy": accuracy,
        "total_sections": len(self.sections_gt),
        "correct_sections": correct_count,
        "section_details": section_details,
    }

calculate_split_accuracy_without_order()

Calculate document split accuracy without considering page order.

For each ground truth section, checks if there's a predicted section with: - Same set of page indices (regardless of order) - Same document_class

Returns:

Type Description
Dict[str, Any]

Dictionary with split accuracy metrics and per-section details

Source code in stickler/doc_split/doc_split_classification_metrics.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
def calculate_split_accuracy_without_order(self) -> Dict[str, Any]:
    """
    Calculate document split accuracy without considering page order.

    For each ground truth section, checks if there's a predicted section with:
    - Same set of page indices (regardless of order)
    - Same document_class

    Returns:
        Dictionary with split accuracy metrics and per-section details
    """
    if not self.sections_gt:
        return {
            "accuracy": 0.0,
            "total_sections": 0,
            "correct_sections": 0,
            "section_details": [],
        }

    correct_count = 0
    section_details = []

    for gt_section in self.sections_gt:
        gt_pages_set = set(gt_section["page_indices"])
        gt_class = gt_section["document_class"]
        section_id = gt_section["section_id"]

        # Find if any predicted section matches (same pages set + same class)
        matched = False
        matched_pred_section = None

        for pred_section in self.sections_pred:
            pred_pages_set = set(pred_section["page_indices"])
            pred_class = pred_section["document_class"]

            if gt_pages_set == pred_pages_set and gt_class == pred_class:
                matched = True
                matched_pred_section = pred_section
                break

        if matched:
            correct_count += 1

        section_details.append(
            {
                "section_id": section_id,
                "ground_truth_class": gt_class,
                "ground_truth_pages": sorted(gt_section["page_indices"]),
                "matched": matched,
                "matched_section_id": (
                    matched_pred_section["section_id"]
                    if matched_pred_section
                    else None
                ),
                "predicted_class": (
                    matched_pred_section["document_class"]
                    if matched_pred_section
                    else "No Match"
                ),
                "predicted_pages": (
                    sorted(matched_pred_section["page_indices"])
                    if matched_pred_section
                    else []
                ),
            }
        )

    accuracy = correct_count / len(self.sections_gt) if self.sections_gt else 0.0

    return {
        "accuracy": accuracy,
        "total_sections": len(self.sections_gt),
        "correct_sections": correct_count,
        "section_details": section_details,
    }

generate_markdown_report(metrics)

Generate a markdown report for document split classification metrics.

Parameters:

Name Type Description Default
metrics Dict[str, Any]

Dictionary containing all calculated metrics (output of calculate_all_metrics)

required

Returns:

Type Description
str

Formatted markdown report string

Source code in stickler/doc_split/doc_split_classification_metrics.py
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
def generate_markdown_report(self, metrics: Dict[str, Any]) -> str:
    """
    Generate a markdown report for document split classification metrics.

    Args:
        metrics: Dictionary containing all calculated metrics
            (output of calculate_all_metrics)

    Returns:
        Formatted markdown report string
    """
    sections = []

    # Title
    sections.append("# Document Split Classification Evaluation")
    sections.append("")

    # Extract metrics
    page_level = metrics["page_level_accuracy"]
    split_no_order = metrics["split_accuracy_without_order"]
    split_with_order = metrics["split_accuracy_with_order"]

    # Helper function for visual indicator
    def get_indicator(accuracy: float) -> str:
        if accuracy >= 0.9:
            return "🟢"
        elif accuracy >= 0.7:
            return "🟡"
        elif accuracy >= 0.5:
            return "🟠"
        else:
            return "🔴"

    def get_rating(accuracy: float) -> str:
        if accuracy >= 0.9:
            return "🟢 Excellent"
        elif accuracy >= 0.7:
            return "🟡 Good"
        elif accuracy >= 0.5:
            return "🟠 Fair"
        else:
            return "🔴 Poor"

    # Summary section
    sections.append("## 🎯 Split Classification Summary")
    sections.append("")

    page_acc = page_level["accuracy"]
    split_no_ord_acc = split_no_order["accuracy"]
    split_ord_acc = split_with_order["accuracy"]

    # Create progress bars
    page_percent = int(page_acc * 100)
    page_progress = f"[{'█' * (page_percent // 5)}{'░' * (20 - page_percent // 5)}] {page_percent}%"

    split_no_ord_percent = int(split_no_ord_acc * 100)
    split_no_ord_progress = f"[{'█' * (split_no_ord_percent // 5)}{'░' * (20 - split_no_ord_percent // 5)}] {split_no_ord_percent}%"

    split_ord_percent = int(split_ord_acc * 100)
    split_ord_progress = f"[{'█' * (split_ord_percent // 5)}{'░' * (20 - split_ord_percent // 5)}] {split_ord_percent}%"

    sections.append(
        f"- **Page Level Accuracy**: {get_indicator(page_acc)} "
        f"{page_level['correct_pages']}/{page_level['total_pages']} pages "
        f"{page_progress}"
    )
    sections.append(
        f"- **Split Accuracy (Without Order)**: {get_indicator(split_no_ord_acc)} "
        f"{split_no_order['correct_sections']}/{split_no_order['total_sections']} sections "
        f"{split_no_ord_progress}"
    )
    sections.append(
        f"- **Split Accuracy (With Order)**: {get_indicator(split_ord_acc)} "
        f"{split_with_order['correct_sections']}/{split_with_order['total_sections']} sections "
        f"{split_ord_progress}"
    )
    sections.append("")

    # Detailed metrics table
    sections.append("## 📊 Split Classification Metrics")
    sections.append("")
    sections.append("| Metric | Accuracy | Rating | Correct/Total |")
    sections.append("| ------ | :------: | :----: | :-----------: |")
    sections.append(
        f"| Page Level Classification | {page_acc:.4f} | {get_rating(page_acc)} | "
        f"{page_level['correct_pages']}/{page_level['total_pages']} pages |"
    )
    sections.append(
        f"| Document Split (Without Page Order) | {split_no_ord_acc:.4f} | {get_rating(split_no_ord_acc)} | "
        f"{split_no_order['correct_sections']}/{split_no_order['total_sections']} sections |"
    )
    sections.append(
        f"| Document Split (With Page Order) | {split_ord_acc:.4f} | {get_rating(split_ord_acc)} | "
        f"{split_with_order['correct_sections']}/{split_with_order['total_sections']} sections |"
    )
    sections.append("")

    # Combined Section split analysis
    sections.append("## 📑 Section Split Analysis")
    sections.append("")

    if split_no_order["section_details"] or split_with_order["section_details"]:
        sections.append(
            "| Section Match | Page Order Match | Section ID | Expected Class | Expected Pages | Pred Class | Pred Pages | Matched Section |"
        )
        sections.append(
            "| :-----------: | :--------------: | ---------- | -------------- | -------------- | ---------- | ---------- | --------------- |"
        )

        # Track matched predicted section IDs
        matched_pred_section_ids = set()

        for idx, section_with_order in enumerate(
            split_with_order["section_details"]
        ):
            section_no_order = (
                split_no_order["section_details"][idx]
                if idx < len(split_no_order["section_details"])
                else None
            )

            section_match = (
                "✅" if section_no_order and section_no_order["matched"] else "❌"
            )
            page_order_match = (
                "✅" if section_with_order.get("order_matched", False) else "❌"
            )

            gt_pages = str(section_with_order["ground_truth_pages"])
            pred_pages = (
                str(section_with_order["predicted_pages"])
                if section_with_order["predicted_pages"]
                else "N/A"
            )
            matched_id = (
                section_with_order["matched_section_id"]
                if section_with_order["matched_section_id"]
                else "N/A"
            )

            if section_with_order["matched_section_id"]:
                matched_pred_section_ids.add(
                    section_with_order["matched_section_id"]
                )

            sections.append(
                f"| {section_match} | {page_order_match} | {section_with_order['section_id']} | "
                f"{section_with_order['ground_truth_class']} | {gt_pages} | {section_with_order['predicted_class']} | "
                f"{pred_pages} | {matched_id} |"
            )

        # Add unmatched predicted sections
        for pred_section in self.sections_pred:
            if pred_section["section_id"] not in matched_pred_section_ids:
                pred_pages = str(pred_section["page_indices"])
                sections.append(
                    f"| ❌ | ❌ | N/A | No Match | N/A | "
                    f"{pred_section['document_class']} | {pred_pages} | {pred_section['section_id']} |"
                )
    else:
        sections.append("*No section data available*")

    sections.append("")

    # Errors section if any
    if metrics.get("errors"):
        sections.append("## ⚠️ Errors Encountered")
        sections.append("")
        for error in metrics["errors"]:
            sections.append(f"- {error}")
        sections.append("")

    # Explanation section
    sections.append("## 📖 Metrics Explanation")
    sections.append("")
    sections.append("### Page Level Accuracy")
    sections.append("- Evaluates classification accuracy for **individual pages**")
    sections.append(
        "- Each page index is checked: does its predicted `document_class` match ground truth?"
    )
    sections.append("- Does not consider page grouping into sections")
    sections.append("")
    sections.append("### Section Split Analysis Table")
    sections.append(
        "- Combines all section-level evaluation metrics into a single comprehensive view"
    )
    sections.append(
        "- Shows both ground truth sections and unmatched predicted sections"
    )
    sections.append("")
    sections.append("**Column Definitions:**")
    sections.append("")
    sections.append(
        "- **Section Match**: ✅ if the expected section's pages (as a set) match any predicted section with the same document class, regardless of page order"
    )
    sections.append(
        "- **Page Order Match**: ✅ if Section Match is true AND the page order also matches exactly"
    )
    sections.append(
        "- **Section ID**: Ground truth section identifier (N/A for unmatched predicted sections)"
    )
    sections.append("- **Expected Class**: Document class from ground truth")
    sections.append("- **Expected Pages**: Page indices from ground truth section")
    sections.append("- **Pred Class**: Predicted document class")
    sections.append("- **Pred Pages**: Predicted page indices")
    sections.append(
        "- **Matched Section**: ID of the predicted section that matched the ground truth section"
    )
    sections.append("")
    sections.append("**Unmatched Predicted Sections:**")
    sections.append("")
    sections.append(
        "- Rows with N/A values represent predicted sections that don't correspond to any ground truth section"
    )
    sections.append(
        "- These indicate potential over-segmentation or incorrect classifications"
    )
    sections.append("")
    sections.append("### Document Split Accuracy Metrics")
    sections.append("")
    sections.append(
        "- **Without Page Order**: Counts sections where pages and class match (order doesn't matter)"
    )
    sections.append(
        "- **With Page Order**: Counts sections where pages, class, and page order all match exactly"
    )
    sections.append("")

    sections.append("---")
    sections.append("")

    return "\n".join(sections)

load_sections(ground_truth_sections, predicted_sections)

Load and parse ground truth and predicted sections.

Each section dict should have
  • section_id: str identifier
  • document_class: str class name (or dict with "type" key)
  • page_indices: list of int page indices (0-based)

Alternatively, the accelerator nested format is also supported: - section_id: str - document_class: {"type": "invoice"} - split_document: {"page_indices": [0, 1, 2]}

Parameters:

Name Type Description Default
ground_truth_sections List[Dict[str, Any]]

List of section dicts with ground truth data

required
predicted_sections List[Dict[str, Any]]

List of section dicts with predicted data

required
Source code in stickler/doc_split/doc_split_classification_metrics.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def load_sections(
    self,
    ground_truth_sections: List[Dict[str, Any]],
    predicted_sections: List[Dict[str, Any]],
) -> None:
    """
    Load and parse ground truth and predicted sections.

    Each section dict should have:
        - section_id: str identifier
        - document_class: str class name (or dict with "type" key)
        - page_indices: list of int page indices (0-based)

    Alternatively, the accelerator nested format is also supported:
        - section_id: str
        - document_class: {"type": "invoice"}
        - split_document: {"page_indices": [0, 1, 2]}

    Args:
        ground_truth_sections: List of section dicts with ground truth data
        predicted_sections: List of section dicts with predicted data
    """
    # Load ground truth sections
    for section in ground_truth_sections:
        section_id = section.get("section_id", f"gt_{len(self.sections_gt)}")
        doc_class = self._get_document_class(section)
        page_indices = self._get_page_indices(section)

        if not page_indices:
            logger.warning(
                f"Section {section_id} has no page indices in ground truth"
            )

        self.sections_gt.append(
            {
                "section_id": section_id,
                "document_class": doc_class,
                "page_indices": page_indices,
            }
        )

        for page_idx in page_indices:
            self.page_classifications_gt[page_idx] = doc_class

    # Load predicted sections
    for section in predicted_sections:
        section_id = section.get("section_id", f"pred_{len(self.sections_pred)}")
        doc_class = self._get_document_class(section)
        page_indices = self._get_page_indices(section)

        if not page_indices:
            logger.warning(
                f"Section {section_id} has no page indices in prediction"
            )

        self.sections_pred.append(
            {
                "section_id": section_id,
                "document_class": doc_class,
                "page_indices": page_indices,
            }
        )

        for page_idx in page_indices:
            self.page_classifications_pred[page_idx] = doc_class

    logger.info(
        f"Loaded {len(self.sections_gt)} ground truth sections "
        f"and {len(self.sections_pred)} predicted sections"
    )

stickler.doc_split.packet_evaluation_metrics.evaluate_packet(data, v_measure_weight=0.5, alpha=0.5, beta=0.5, strict_clustering=False)

Evaluate document packet splitting performance.

Accepts flexible input (list of dicts, CSV path, or DataFrame) and computes clustering + ordering metrics.

Required columns
  • group_id: Ground truth document group
  • group_id_predicted: Predicted document group
  • page_number: Ground truth page order within document
  • page_number_predicted: Predicted page order

For strict_clustering=True, also requires: - class_label: Ground truth document type - class_label_predicted: Predicted document type

Parameters:

Name Type Description Default
data Union[List[Dict[str, Any]], str, DataFrame]

Page-level evaluation data.

required
v_measure_weight float

Weight for V-measure in clustering score.

0.5
alpha float

Weight for clustering in final score.

0.5
beta float

Weight for ordering in final score.

0.5
strict_clustering bool

If True, penalize misclassification in clustering.

False

Returns:

Type Description
Dict[str, Any]

Dict with final_score, clustering_score, v_measure, rand_index,

Dict[str, Any]

avg_ordering_score, group_ordering_scores, strict_clustering.

Source code in stickler/doc_split/packet_evaluation_metrics.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
def evaluate_packet(
    data: Union[List[Dict[str, Any]], str, pd.DataFrame],
    v_measure_weight: float = 0.5,
    alpha: float = 0.5,
    beta: float = 0.5,
    strict_clustering: bool = False,
) -> Dict[str, Any]:
    """
    Evaluate document packet splitting performance.

    Accepts flexible input (list of dicts, CSV path, or DataFrame) and
    computes clustering + ordering metrics.

    Required columns:
        - group_id: Ground truth document group
        - group_id_predicted: Predicted document group
        - page_number: Ground truth page order within document
        - page_number_predicted: Predicted page order

    For strict_clustering=True, also requires:
        - class_label: Ground truth document type
        - class_label_predicted: Predicted document type

    Args:
        data: Page-level evaluation data.
        v_measure_weight: Weight for V-measure in clustering score.
        alpha: Weight for clustering in final score.
        beta: Weight for ordering in final score.
        strict_clustering: If True, penalize misclassification in clustering.

    Returns:
        Dict with final_score, clustering_score, v_measure, rand_index,
        avg_ordering_score, group_ordering_scores, strict_clustering.
    """
    df = _to_dataframe(data)

    if df.empty:
        return {
            "final_score": 0.0,
            "clustering_score": 0.0,
            "v_measure": 0.0,
            "rand_index": 0.0,
            "avg_ordering_score": 0.0,
            "group_ordering_scores": {},
            "strict_clustering": strict_clustering,
        }

    clustering_score, v_measure, ri = calculate_clustering_score(
        df, v_measure_weight, strict_clustering
    )

    group_scores = calculate_ordering_score_per_group(df)
    avg_ordering = calculate_average_ordering_score(group_scores)

    final = calculate_final_score(
        clustering_score, v_measure, avg_ordering, alpha, beta
    )

    return {
        "final_score": final,
        "clustering_score": clustering_score,
        "v_measure": v_measure,
        "rand_index": ri,
        "avg_ordering_score": avg_ordering,
        "group_ordering_scores": group_scores,
        "strict_clustering": strict_clustering,
    }