修改为东南天坐标系
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
FrequencyDict: TypeAlias = "dict[tuple[str, int | None], int]"
|
||||
"""Like:
|
||||
{
|
||||
("ListItem", 0): 2,
|
||||
("NarrativeText", None): 2,
|
||||
("Title", 0): 5,
|
||||
("UncategorizedText", None): 6,
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def get_element_type_frequency(
|
||||
elements: str,
|
||||
) -> FrequencyDict:
|
||||
"""
|
||||
Calculate the frequency of Element Types from a list of elements.
|
||||
|
||||
Args:
|
||||
elements (str): String-formatted json of all elements (as a result of elements_to_json).
|
||||
Returns:
|
||||
Element type and its frequency in dictionary format.
|
||||
"""
|
||||
frequency: dict[tuple[str, int | None], int] = {}
|
||||
if len(elements) == 0:
|
||||
return frequency
|
||||
for element in json.loads(elements):
|
||||
type = element.get("type")
|
||||
category_depth = element["metadata"].get("category_depth")
|
||||
key = (type, category_depth)
|
||||
if key not in frequency:
|
||||
frequency[key] = 1
|
||||
else:
|
||||
frequency[key] += 1
|
||||
return frequency
|
||||
|
||||
|
||||
def calculate_element_type_percent_match(
|
||||
output: FrequencyDict,
|
||||
source: FrequencyDict,
|
||||
category_depth_weight: float = 0.5,
|
||||
) -> float:
|
||||
"""Calculate the percent match between two frequency dictionary.
|
||||
|
||||
Intended to use with `get_element_type_frequency` function. The function counts the absolute
|
||||
exact match (type and depth), and counts the weighted match (correct type but different depth),
|
||||
then normalized with source's total elements.
|
||||
"""
|
||||
if len(output) == 0 or len(source) == 0:
|
||||
return 0.0
|
||||
|
||||
output_copy = output.copy()
|
||||
source_copy = source.copy()
|
||||
total_source_element_count = 0
|
||||
total_match_element_count = 0
|
||||
|
||||
unmatched_depth_output: dict[str, int] = {}
|
||||
unmatched_depth_source: dict[str, int] = {}
|
||||
|
||||
# loop through the output list to find match with source
|
||||
for k, _ in output_copy.items():
|
||||
if k in source_copy:
|
||||
match_count = min(output_copy[k], source_copy[k])
|
||||
total_match_element_count += match_count
|
||||
total_source_element_count += match_count
|
||||
|
||||
# update the dictionary by removing already matched values
|
||||
output_copy[k] -= match_count
|
||||
source_copy[k] -= match_count
|
||||
|
||||
# add unmatched leftovers from output_copy to a new dictionary
|
||||
element_type = k[0]
|
||||
if element_type not in unmatched_depth_output:
|
||||
unmatched_depth_output[element_type] = output_copy[k]
|
||||
else:
|
||||
unmatched_depth_output[element_type] += output_copy[k]
|
||||
|
||||
# add unmatched leftovers from source_copy to a new dictionary
|
||||
unmatched_depth_source = _convert_to_frequency_without_depth(source_copy)
|
||||
|
||||
# loop through the source list to match any existing partial match left
|
||||
for k, _ in unmatched_depth_source.items():
|
||||
total_source_element_count += unmatched_depth_source[k]
|
||||
if k in unmatched_depth_output:
|
||||
match_count = min(unmatched_depth_output[k], unmatched_depth_source[k])
|
||||
total_match_element_count += match_count * category_depth_weight
|
||||
|
||||
return min(max(total_match_element_count / total_source_element_count, 0.0), 1.0)
|
||||
|
||||
|
||||
def _convert_to_frequency_without_depth(d: FrequencyDict) -> dict[str, int]:
|
||||
"""
|
||||
Takes in element frequency with depth of format (type, depth): value
|
||||
and converts to dictionary without depth of format type: value
|
||||
"""
|
||||
res: dict[str, int] = {}
|
||||
for k, v in d.items():
|
||||
element_type = k[0]
|
||||
if element_type not in res:
|
||||
res[element_type] = v
|
||||
else:
|
||||
res[element_type] += v
|
||||
return res
|
||||
@@ -0,0 +1,897 @@
|
||||
#! /usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from tqdm import tqdm
|
||||
|
||||
from unstructured.metrics.element_type import (
|
||||
calculate_element_type_percent_match,
|
||||
get_element_type_frequency,
|
||||
)
|
||||
from unstructured.metrics.object_detection import (
|
||||
ObjectDetectionEvalProcessor,
|
||||
)
|
||||
from unstructured.metrics.table.table_eval import TableEvalProcessor
|
||||
from unstructured.metrics.text_extraction import calculate_accuracy, calculate_percent_missing_text
|
||||
from unstructured.metrics.utils import (
|
||||
_count,
|
||||
_display,
|
||||
_format_grouping_output,
|
||||
_mean,
|
||||
_prepare_output_cct,
|
||||
_pstdev,
|
||||
_read_text_file,
|
||||
_rename_aggregated_columns,
|
||||
_stdev,
|
||||
_write_to_file,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("unstructured.eval")
|
||||
handler = logging.StreamHandler()
|
||||
handler.name = "eval_log_handler"
|
||||
formatter = logging.Formatter("%(asctime)s %(processName)-10s %(levelname)-8s %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
# Only want to add the handler once
|
||||
if "eval_log_handler" not in [h.name for h in logger.handlers]:
|
||||
logger.addHandler(handler)
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
AGG_HEADERS = ["metric", "average", "sample_sd", "population_sd", "count"]
|
||||
AGG_HEADERS_MAPPING = {
|
||||
"index": "metric",
|
||||
"_mean": "average",
|
||||
"_stdev": "sample_sd",
|
||||
"_pstdev": "population_sd",
|
||||
"_count": "count",
|
||||
}
|
||||
OUTPUT_TYPE_OPTIONS = ["json", "txt"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseMetricsCalculator(ABC):
|
||||
"""Foundation class for specialized metrics calculators.
|
||||
|
||||
It provides a common interface for calculating metrics based on outputs and ground truths.
|
||||
Those can be provided as either directories or lists of files.
|
||||
"""
|
||||
|
||||
documents_dir: str | Path
|
||||
ground_truths_dir: str | Path
|
||||
|
||||
def __post_init__(self):
|
||||
"""Discover all files in the provided directories."""
|
||||
self.documents_dir = Path(self.documents_dir).resolve()
|
||||
self.ground_truths_dir = Path(self.ground_truths_dir).resolve()
|
||||
|
||||
# -- auto-discover all files in the directories --
|
||||
self._document_paths = [
|
||||
path.relative_to(self.documents_dir)
|
||||
for path in self.documents_dir.glob("*")
|
||||
if path.is_file()
|
||||
]
|
||||
self._ground_truth_paths = [
|
||||
path.relative_to(self.ground_truths_dir)
|
||||
for path in self.ground_truths_dir.glob("*")
|
||||
if path.is_file()
|
||||
]
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def default_tsv_name(self):
|
||||
"""Default name for the per-document metrics TSV file."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def default_agg_tsv_name(self):
|
||||
"""Default name for the aggregated metrics TSV file."""
|
||||
|
||||
@abstractmethod
|
||||
def _generate_dataframes(self, rows: list) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""Generates pandas DataFrames from the list of rows.
|
||||
|
||||
The first DF (index 0) is a dataframe containing metrics per file.
|
||||
The second DF (index 1) is a dataframe containing the aggregated
|
||||
metrics.
|
||||
"""
|
||||
|
||||
def on_files(
|
||||
self,
|
||||
document_paths: Optional[list[str | Path]] = None,
|
||||
ground_truth_paths: Optional[list[str | Path]] = None,
|
||||
) -> BaseMetricsCalculator:
|
||||
"""Overrides the default list of files to process."""
|
||||
if document_paths:
|
||||
self._document_paths = [Path(p) for p in document_paths]
|
||||
|
||||
if ground_truth_paths:
|
||||
self._ground_truth_paths = [Path(p) for p in ground_truth_paths]
|
||||
|
||||
return self
|
||||
|
||||
def calculate(
|
||||
self,
|
||||
executor: Optional[concurrent.futures.Executor] = None,
|
||||
export_dir: Optional[str | Path] = None,
|
||||
visualize_progress: bool = True,
|
||||
display_agg_df: bool = True,
|
||||
) -> pd.DataFrame:
|
||||
"""Calculates metrics for each document using the provided executor.
|
||||
|
||||
* Optionally, the results can be exported and displayed.
|
||||
* It loops through the list of structured output from all of `documents_dir` or
|
||||
selected files from `document_paths`, and compares them with gold-standard
|
||||
of the same file name under `ground_truths_dir` or selected files from `ground_truth_paths`.
|
||||
|
||||
Args:
|
||||
executor: concurrent.futures.Executor instance
|
||||
export_dir: directory to export the results
|
||||
visualize_progress: whether to display progress bar
|
||||
display_agg_df: whether to display the aggregated results
|
||||
|
||||
Returns:
|
||||
Metrics for each document as a pandas DataFrame
|
||||
"""
|
||||
if executor is None:
|
||||
executor = self._default_executor()
|
||||
rows = self._process_all_documents(executor, visualize_progress)
|
||||
df, agg_df = self._generate_dataframes(rows)
|
||||
|
||||
if export_dir is not None:
|
||||
_write_to_file(export_dir, self.default_tsv_name, df)
|
||||
_write_to_file(export_dir, self.default_agg_tsv_name, agg_df)
|
||||
|
||||
if display_agg_df is True:
|
||||
_display(agg_df)
|
||||
return df
|
||||
|
||||
@classmethod
|
||||
def _default_executor(cls):
|
||||
max_processors = int(os.environ.get("MAX_PROCESSES", os.cpu_count()))
|
||||
logger.info(f"Configuring a pool of {max_processors} processors for parallel processing.")
|
||||
return cls._get_executor_class()(max_workers=max_processors)
|
||||
|
||||
@classmethod
|
||||
def _get_executor_class(
|
||||
cls,
|
||||
) -> type[concurrent.futures.ThreadPoolExecutor] | type[concurrent.futures.ProcessPoolExecutor]:
|
||||
return concurrent.futures.ProcessPoolExecutor
|
||||
|
||||
def _process_all_documents(
|
||||
self, executor: concurrent.futures.Executor, visualize_progress: bool
|
||||
) -> list:
|
||||
"""Triggers processing of all documents using the provided executor.
|
||||
|
||||
Failures are omitted from the returned result.
|
||||
"""
|
||||
with executor:
|
||||
return [
|
||||
row
|
||||
for row in tqdm(
|
||||
executor.map(self._try_process_document, self._document_paths),
|
||||
total=len(self._document_paths),
|
||||
leave=False,
|
||||
disable=not visualize_progress,
|
||||
)
|
||||
if row is not None
|
||||
]
|
||||
|
||||
def _try_process_document(self, doc: Path) -> Optional[list]:
|
||||
"""Safe wrapper around the document processing method."""
|
||||
logger.info(f"Processing {doc}")
|
||||
try:
|
||||
return self._process_document(doc)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process document {doc}: {e}")
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def _process_document(self, doc: Path) -> Optional[list]:
|
||||
"""Should return all metadata and metrics for a single document."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableStructureMetricsCalculator(BaseMetricsCalculator):
|
||||
"""Calculates the following metrics for tables:
|
||||
- tables found accuracy
|
||||
- table-level accuracy
|
||||
- element in column index accuracy
|
||||
- element in row index accuracy
|
||||
- element's column content accuracy
|
||||
- element's row content accuracy
|
||||
It also calculates the aggregated accuracy.
|
||||
"""
|
||||
|
||||
cutoff: Optional[float] = None
|
||||
weighted_average: bool = True
|
||||
include_false_positives: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
|
||||
@property
|
||||
def supported_metric_names(self):
|
||||
return [
|
||||
"total_tables",
|
||||
"table_level_acc",
|
||||
"table_detection_recall",
|
||||
"table_detection_precision",
|
||||
"table_detection_f1",
|
||||
"composite_structure_acc",
|
||||
"element_col_level_index_acc",
|
||||
"element_row_level_index_acc",
|
||||
"element_col_level_content_acc",
|
||||
"element_row_level_content_acc",
|
||||
]
|
||||
|
||||
@property
|
||||
def default_tsv_name(self):
|
||||
return "all-docs-table-structure-accuracy.tsv"
|
||||
|
||||
@property
|
||||
def default_agg_tsv_name(self):
|
||||
return "aggregate-table-structure-accuracy.tsv"
|
||||
|
||||
def _process_document(self, doc: Path) -> Optional[list]:
|
||||
doc_path = Path(doc)
|
||||
out_filename = doc_path.stem
|
||||
doctype = Path(out_filename).suffix
|
||||
src_gt_filename = out_filename + ".json"
|
||||
connector = doc_path.parts[-2] if len(doc_path.parts) > 1 else None
|
||||
|
||||
if src_gt_filename in self._ground_truth_paths: # type: ignore
|
||||
return None
|
||||
|
||||
prediction_file = self.documents_dir / doc
|
||||
if not prediction_file.exists():
|
||||
logger.warning(f"Prediction file {prediction_file} does not exist, skipping")
|
||||
return None
|
||||
|
||||
ground_truth_file = self.ground_truths_dir / src_gt_filename
|
||||
if not ground_truth_file.exists():
|
||||
logger.warning(f"Ground truth file {ground_truth_file} does not exist, skipping")
|
||||
return None
|
||||
|
||||
processor_from_text_as_html = TableEvalProcessor.from_json_files(
|
||||
prediction_file=prediction_file,
|
||||
ground_truth_file=ground_truth_file,
|
||||
cutoff=self.cutoff,
|
||||
source_type="html",
|
||||
)
|
||||
report_from_html = processor_from_text_as_html.process_file()
|
||||
return [
|
||||
out_filename,
|
||||
doctype,
|
||||
connector,
|
||||
report_from_html.total_predicted_tables,
|
||||
] + [getattr(report_from_html, metric) for metric in self.supported_metric_names]
|
||||
|
||||
def _generate_dataframes(self, rows):
|
||||
headers = [
|
||||
"filename",
|
||||
"doctype",
|
||||
"connector",
|
||||
"total_predicted_tables",
|
||||
] + self.supported_metric_names
|
||||
|
||||
df = pd.DataFrame(rows, columns=headers)
|
||||
df["_table_weights"] = df["total_tables"]
|
||||
|
||||
if self.include_false_positives:
|
||||
# we give false positive tables a 1 table worth of weight in computing table level acc
|
||||
df["_table_weights"][df.total_tables.eq(0) & df.total_predicted_tables.gt(0)] = 1
|
||||
|
||||
# filter down to only those with actual and/or predicted tables
|
||||
has_tables_df = df[df["_table_weights"] > 0]
|
||||
|
||||
if not self.weighted_average:
|
||||
# for all non zero elements assign them value 1
|
||||
df["_table_weights"] = df["_table_weights"].apply(
|
||||
lambda table_weight: 1 if table_weight != 0 else 0
|
||||
)
|
||||
|
||||
if has_tables_df.empty:
|
||||
agg_df = pd.DataFrame(
|
||||
[[metric, None, None, None, 0] for metric in self.supported_metric_names]
|
||||
).reset_index()
|
||||
else:
|
||||
element_metrics_results = {}
|
||||
for metric in self.supported_metric_names:
|
||||
metric_df = has_tables_df[has_tables_df[metric].notnull()]
|
||||
agg_metric = metric_df[metric].agg([_stdev, _pstdev, _count]).transpose()
|
||||
if metric.startswith("total_tables"):
|
||||
agg_metric["_mean"] = metric_df[metric].mean()
|
||||
elif metric.startswith("table_level_acc"):
|
||||
agg_metric["_mean"] = np.round(
|
||||
np.average(metric_df[metric], weights=metric_df["_table_weights"]),
|
||||
3,
|
||||
)
|
||||
else:
|
||||
# false positive tables do not contribute to table structure and content
|
||||
# extraction metrics
|
||||
agg_metric["_mean"] = np.round(
|
||||
np.average(metric_df[metric], weights=metric_df["total_tables"]),
|
||||
3,
|
||||
)
|
||||
if agg_metric.empty:
|
||||
element_metrics_results[metric] = pd.Series(
|
||||
data=[None, None, None, 0], index=["_mean", "_stdev", "_pstdev", "_count"]
|
||||
)
|
||||
else:
|
||||
element_metrics_results[metric] = agg_metric
|
||||
agg_df = pd.DataFrame(element_metrics_results).transpose().reset_index()
|
||||
agg_df = agg_df.rename(columns=AGG_HEADERS_MAPPING)
|
||||
return df, agg_df
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextExtractionMetricsCalculator(BaseMetricsCalculator):
|
||||
"""Calculates text accuracy and percent missing between document and ground truth texts.
|
||||
|
||||
It also calculates the aggregated accuracy and percent missing.
|
||||
"""
|
||||
|
||||
group_by: Optional[str] = None
|
||||
weights: tuple[int, int, int] = (1, 1, 1)
|
||||
document_type: str = "json"
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self._validate_inputs()
|
||||
|
||||
@property
|
||||
def default_tsv_name(self) -> str:
|
||||
return "all-docs-cct.tsv"
|
||||
|
||||
@property
|
||||
def default_agg_tsv_name(self) -> str:
|
||||
return "aggregate-scores-cct.tsv"
|
||||
|
||||
def calculate(
|
||||
self,
|
||||
executor: Optional[concurrent.futures.Executor] = None,
|
||||
export_dir: Optional[str | Path] = None,
|
||||
visualize_progress: bool = True,
|
||||
display_agg_df: bool = True,
|
||||
) -> pd.DataFrame:
|
||||
"""See the parent class for the method's docstring."""
|
||||
df = super().calculate(
|
||||
executor=executor,
|
||||
export_dir=export_dir,
|
||||
visualize_progress=visualize_progress,
|
||||
display_agg_df=display_agg_df,
|
||||
)
|
||||
|
||||
if export_dir is not None and self.group_by:
|
||||
get_mean_grouping(self.group_by, df, export_dir, "text_extraction")
|
||||
return df
|
||||
|
||||
def _validate_inputs(self):
|
||||
if not self._document_paths:
|
||||
logger.info("No output files to calculate to edit distances for, exiting")
|
||||
sys.exit(0)
|
||||
if self.document_type not in OUTPUT_TYPE_OPTIONS:
|
||||
raise ValueError(
|
||||
"Specified file type under `documents_dir` or `output_list` should be one of "
|
||||
f"`json` or `txt`. The given file type is {self.document_type}, exiting."
|
||||
)
|
||||
for path in self._document_paths:
|
||||
try:
|
||||
path.suffixes[-1]
|
||||
except IndexError:
|
||||
logger.error(f"File {path} does not have a suffix, skipping")
|
||||
continue
|
||||
if path.suffixes[-1] != f".{self.document_type}":
|
||||
logger.warning(
|
||||
"The directory contains file type inconsistent with the given input. "
|
||||
"Please note that some files will be skipped."
|
||||
)
|
||||
if not all(path.suffixes[-1] == f".{self.document_type}" for path in self._document_paths):
|
||||
logger.warning(
|
||||
"The directory contains file type inconsistent with the given input. "
|
||||
"Please note that some files will be skipped."
|
||||
)
|
||||
|
||||
def _process_document(self, doc: Path) -> Optional[list]:
|
||||
filename = doc.stem
|
||||
doctype = doc.suffixes[-2]
|
||||
connector = doc.parts[0] if len(doc.parts) > 1 else None
|
||||
|
||||
output_cct, source_cct = self._get_ccts(doc)
|
||||
# NOTE(amadeusz): Levenshtein distance calculation takes too long
|
||||
# skip it if file sizes differ wildly
|
||||
if 0.5 < len(output_cct.encode()) / len(source_cct.encode()) < 2.0:
|
||||
accuracy = round(calculate_accuracy(output_cct, source_cct, self.weights), 3)
|
||||
else:
|
||||
# 0.01 to distinguish it was set manually
|
||||
accuracy = 0.01
|
||||
percent_missing = round(calculate_percent_missing_text(output_cct, source_cct), 3)
|
||||
return [filename, doctype, connector, accuracy, percent_missing]
|
||||
|
||||
def _get_ccts(self, doc: Path) -> tuple[str, str]:
|
||||
output_cct = _prepare_output_cct(
|
||||
docpath=self.documents_dir / doc, output_type=self.document_type
|
||||
)
|
||||
source_cct = _read_text_file(self.ground_truths_dir / doc.with_suffix(".txt"))
|
||||
|
||||
return output_cct, source_cct
|
||||
|
||||
def _generate_dataframes(self, rows):
|
||||
headers = ["filename", "doctype", "connector", "cct-accuracy", "cct-%missing"]
|
||||
df = pd.DataFrame(rows, columns=headers)
|
||||
|
||||
acc = df[["cct-accuracy"]].agg([_mean, _stdev, _pstdev, _count]).transpose()
|
||||
miss = df[["cct-%missing"]].agg([_mean, _stdev, _pstdev, _count]).transpose()
|
||||
if acc.shape[1] == 0 and miss.shape[1] == 0:
|
||||
agg_df = pd.DataFrame(columns=AGG_HEADERS)
|
||||
else:
|
||||
agg_df = pd.concat((acc, miss)).reset_index()
|
||||
agg_df.columns = AGG_HEADERS
|
||||
|
||||
return df, agg_df
|
||||
|
||||
|
||||
@dataclass
|
||||
class ElementTypeMetricsCalculator(BaseMetricsCalculator):
|
||||
"""
|
||||
Calculates element type frequency accuracy, percent missing and
|
||||
aggregated accuracy between document and ground truth.
|
||||
"""
|
||||
|
||||
group_by: Optional[str] = None
|
||||
|
||||
def calculate(
|
||||
self,
|
||||
executor: Optional[concurrent.futures.Executor] = None,
|
||||
export_dir: Optional[str | Path] = None,
|
||||
visualize_progress: bool = True,
|
||||
display_agg_df: bool = False,
|
||||
) -> pd.DataFrame:
|
||||
"""See the parent class for the method's docstring."""
|
||||
df = super().calculate(
|
||||
executor=executor,
|
||||
export_dir=export_dir,
|
||||
visualize_progress=visualize_progress,
|
||||
display_agg_df=display_agg_df,
|
||||
)
|
||||
|
||||
if export_dir is not None and self.group_by:
|
||||
get_mean_grouping(self.group_by, df, export_dir, "element_type")
|
||||
return df
|
||||
|
||||
@property
|
||||
def default_tsv_name(self) -> str:
|
||||
return "all-docs-element-type-frequency.tsv"
|
||||
|
||||
@property
|
||||
def default_agg_tsv_name(self) -> str:
|
||||
return "aggregate-scores-element-type.tsv"
|
||||
|
||||
def _process_document(self, doc: Path) -> Optional[list]:
|
||||
filename = doc.stem
|
||||
doctype = doc.suffixes[-2]
|
||||
connector = doc.parts[0] if len(doc.parts) > 1 else None
|
||||
|
||||
output = get_element_type_frequency(_read_text_file(self.documents_dir / doc))
|
||||
source = get_element_type_frequency(
|
||||
_read_text_file(self.ground_truths_dir / doc.with_suffix(".json"))
|
||||
)
|
||||
accuracy = round(calculate_element_type_percent_match(output, source), 3)
|
||||
return [filename, doctype, connector, accuracy]
|
||||
|
||||
def _generate_dataframes(self, rows):
|
||||
headers = ["filename", "doctype", "connector", "element-type-accuracy"]
|
||||
df = pd.DataFrame(rows, columns=headers)
|
||||
if df.empty:
|
||||
agg_df = pd.DataFrame(["element-type-accuracy", None, None, None, 0]).transpose()
|
||||
else:
|
||||
agg_df = df.agg({"element-type-accuracy": [_mean, _stdev, _pstdev, _count]}).transpose()
|
||||
agg_df = agg_df.reset_index()
|
||||
|
||||
agg_df.columns = AGG_HEADERS
|
||||
|
||||
return df, agg_df
|
||||
|
||||
|
||||
def get_mean_grouping(
|
||||
group_by: str,
|
||||
data_input: Union[pd.DataFrame, str],
|
||||
export_dir: str,
|
||||
eval_name: str,
|
||||
agg_name: Optional[str] = None,
|
||||
export_filename: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Aggregates accuracy and missing metrics by column name 'doctype' or 'connector',
|
||||
or 'all' for all rows. Export to TSV.
|
||||
If `all`, passing export_name is recommended.
|
||||
|
||||
Args:
|
||||
group_by (str): Grouping category ('doctype' or 'connector' or 'all').
|
||||
data_input (Union[pd.DataFrame, str]): DataFrame or path to a CSV/TSV file.
|
||||
export_dir (str): Directory for the exported TSV file.
|
||||
eval_name (str): Evaluated metric ('text_extraction' or 'element_type').
|
||||
agg_name (str, optional): String to use with export filename. Default is `cct` for
|
||||
group_by `text_extraction` and `element-type` for `element_type`
|
||||
export_name (str, optional): Export filename.
|
||||
"""
|
||||
if group_by not in ("doctype", "connector") and group_by != "all":
|
||||
raise ValueError("Invalid grouping category. Returning a non-group evaluation.")
|
||||
|
||||
if eval_name == "text_extraction":
|
||||
agg_fields = ["cct-accuracy", "cct-%missing"]
|
||||
agg_name = "cct"
|
||||
elif eval_name == "element_type":
|
||||
agg_fields = ["element-type-accuracy"]
|
||||
agg_name = "element-type"
|
||||
elif eval_name == "object_detection":
|
||||
agg_fields = ["f1_score", "m_ap"]
|
||||
agg_name = "object-detection"
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown metric for eval {eval_name}. "
|
||||
f"Expected `text_extraction` or `element_type` or `table_extraction`."
|
||||
)
|
||||
|
||||
if isinstance(data_input, str):
|
||||
if not os.path.exists(data_input):
|
||||
raise FileNotFoundError(f"File {data_input} not found.")
|
||||
if data_input.endswith(".csv"):
|
||||
df = pd.read_csv(data_input, header=None)
|
||||
elif data_input.endswith(".tsv"):
|
||||
df = pd.read_csv(data_input, sep="\t")
|
||||
elif data_input.endswith(".txt"):
|
||||
df = pd.read_csv(data_input, sep="\t", header=None)
|
||||
else:
|
||||
raise ValueError("Please provide a .csv or .tsv file.")
|
||||
else:
|
||||
df = data_input
|
||||
|
||||
if df.empty:
|
||||
raise SystemExit("Data is empty. Exiting.")
|
||||
elif group_by != "all" and (group_by not in df.columns or df[group_by].isnull().all()):
|
||||
raise SystemExit(
|
||||
f"Data cannot be aggregated by `{group_by}`."
|
||||
f" Check if it's empty or the column is missing/empty."
|
||||
)
|
||||
|
||||
grouped_df = []
|
||||
if group_by and group_by != "all":
|
||||
for field in agg_fields:
|
||||
grouped_df.append(
|
||||
_rename_aggregated_columns(
|
||||
df.groupby(group_by).agg({field: [_mean, _stdev, _pstdev, _count]})
|
||||
)
|
||||
)
|
||||
if group_by == "all":
|
||||
df["grouping_key"] = 0
|
||||
for field in agg_fields:
|
||||
grouped_df.append(
|
||||
_rename_aggregated_columns(
|
||||
df.groupby("grouping_key").agg({field: [_mean, _stdev, _pstdev, _count]})
|
||||
)
|
||||
)
|
||||
grouped_df = _format_grouping_output(*grouped_df)
|
||||
if "grouping_key" in grouped_df.columns.get_level_values(0):
|
||||
grouped_df = grouped_df.drop("grouping_key", axis=1, level=0)
|
||||
|
||||
if export_filename:
|
||||
if not export_filename.endswith(".tsv"):
|
||||
export_filename = export_filename + ".tsv"
|
||||
_write_to_file(export_dir, export_filename, grouped_df)
|
||||
else:
|
||||
_write_to_file(export_dir, f"all-{group_by}-agg-{agg_name}.tsv", grouped_df)
|
||||
|
||||
|
||||
def filter_metrics(
|
||||
data_input: Union[str, pd.DataFrame],
|
||||
filter_list: Union[str, List[str]],
|
||||
filter_by: str = "filename",
|
||||
export_filename: Optional[str] = None,
|
||||
export_dir: str = "metrics",
|
||||
return_type: str = "file",
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Reads the data_input file and filter only selected row available in filter_list.
|
||||
|
||||
Args:
|
||||
data_input (str, dataframe): the source data, path to file or dataframe
|
||||
filter_list (str, list): the filter, path to file or list of string
|
||||
filter_by (str): data_input's column to filter the filter_list to
|
||||
export_filename (str, optional): export filename. required when return_type is "file"
|
||||
export_dir (str, optional): export directory. default to <current directory>/metrics
|
||||
return_type (str): "file" or "dataframe"
|
||||
"""
|
||||
if isinstance(data_input, str):
|
||||
if not os.path.exists(data_input):
|
||||
raise FileNotFoundError(f"File {data_input} not found.")
|
||||
if data_input.endswith(".csv"):
|
||||
df = pd.read_csv(data_input, header=None)
|
||||
elif data_input.endswith(".tsv"):
|
||||
df = pd.read_csv(data_input, sep="\t")
|
||||
elif data_input.endswith(".txt"):
|
||||
df = pd.read_csv(data_input, sep="\t", header=None)
|
||||
else:
|
||||
raise ValueError("Please provide a .csv or .tsv file.")
|
||||
else:
|
||||
df = data_input
|
||||
|
||||
if isinstance(filter_list, str):
|
||||
if not os.path.exists(filter_list):
|
||||
raise FileNotFoundError(f"File {filter_list} not found.")
|
||||
if filter_list.endswith(".csv"):
|
||||
filter_df = pd.read_csv(filter_list, header=None)
|
||||
elif filter_list.endswith(".tsv"):
|
||||
filter_df = pd.read_csv(filter_list, sep="\t")
|
||||
elif filter_list.endswith(".txt"):
|
||||
filter_df = pd.read_csv(filter_list, sep="\t", header=None)
|
||||
else:
|
||||
raise ValueError("Please provide a .csv or .tsv file.")
|
||||
filter_list = filter_df.iloc[:, 0].astype(str).values.tolist()
|
||||
elif not isinstance(filter_list, list):
|
||||
raise ValueError("Please provide a List of strings or path to file.")
|
||||
|
||||
if filter_by not in df.columns:
|
||||
raise ValueError("`filter_by` key does not exists in the data provided.")
|
||||
|
||||
res = df[df[filter_by].isin(filter_list)]
|
||||
|
||||
if res.empty:
|
||||
raise SystemExit("No common file names between data_input and filter_list. Exiting.")
|
||||
|
||||
if return_type == "dataframe":
|
||||
return res
|
||||
elif return_type == "file" and export_filename:
|
||||
_write_to_file(export_dir, export_filename, res)
|
||||
elif return_type == "file" and not export_filename:
|
||||
raise ValueError("Please provide `export_filename`.")
|
||||
else:
|
||||
raise ValueError("Return type must be either `dataframe` or `file`.")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObjectDetectionMetricsCalculatorBase(BaseMetricsCalculator, ABC):
|
||||
"""
|
||||
Calculates object detection metrics for each document:
|
||||
- f1 score
|
||||
- precision
|
||||
- recall
|
||||
- average precision (mAP)
|
||||
It also calculates aggregated metrics.
|
||||
"""
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self._document_paths = [
|
||||
path.relative_to(self.documents_dir)
|
||||
for path in self.documents_dir.rglob("analysis/*/layout_dump/object_detection.json")
|
||||
if path.is_file()
|
||||
]
|
||||
|
||||
@property
|
||||
def supported_metric_names(self):
|
||||
return ["f1_score", "precision", "recall", "m_ap"]
|
||||
|
||||
@property
|
||||
def default_tsv_name(self):
|
||||
return "all-docs-object-detection-metrics.tsv"
|
||||
|
||||
@property
|
||||
def default_agg_tsv_name(self):
|
||||
return "aggregate-object-detection-metrics.tsv"
|
||||
|
||||
def _find_file_in_ground_truth(self, file_stem: str) -> Optional[Path]:
|
||||
"""Find the file corresponding to OD model dump file among the set of ground truth files
|
||||
|
||||
The files in ground truth paths keep the original extension and have .json suffix added,
|
||||
e.g.:
|
||||
some_document.pdf.json
|
||||
poster.jpg.json
|
||||
|
||||
To compare to `file_stem` we need to take the prefix part of the file, thus double-stem
|
||||
is applied.
|
||||
"""
|
||||
for path in self._ground_truth_paths:
|
||||
if Path(path.stem).stem == file_stem:
|
||||
return path
|
||||
return None
|
||||
|
||||
def _get_paths(self, doc: Path) -> tuple(str, Path, Path):
|
||||
"""Resolves ground doctype, prediction file path and ground truth path.
|
||||
|
||||
As OD dump directory structure differes from other simple outputs, it needs
|
||||
a specific processing to match the output OD dump file with corresponding
|
||||
OD GT file.
|
||||
|
||||
The outputs are placed in a dicrectory structure:
|
||||
|
||||
analysis
|
||||
|- document_name
|
||||
|- layout_dump
|
||||
|- object_detection.json
|
||||
|- bboxes # not used in this evaluation
|
||||
|
||||
and the GT file is pleced in od_gt directory for given dataset
|
||||
|
||||
dataset_name
|
||||
|- od_gt
|
||||
|- document_name.pdf.json
|
||||
|
||||
Args:
|
||||
doc (Path): path to the OD dump file
|
||||
|
||||
Returns:
|
||||
tuple: doctype, prediction file path, ground truth path
|
||||
"""
|
||||
od_dump_path = Path(doc)
|
||||
file_stem = od_dump_path.parts[-3] # we take the `document_name` - so the filename stem
|
||||
|
||||
src_gt_filename = self._find_file_in_ground_truth(file_stem)
|
||||
|
||||
if src_gt_filename not in self._ground_truth_paths:
|
||||
raise ValueError(f"Ground truth file {src_gt_filename} not found in list of GT files")
|
||||
|
||||
doctype = Path(src_gt_filename.stem).suffix[1:]
|
||||
|
||||
prediction_file = self.documents_dir / doc
|
||||
if not prediction_file.exists():
|
||||
logger.warning(f"Prediction file {prediction_file} does not exist, skipping")
|
||||
raise ValueError(f"Prediction file {prediction_file} does not exist")
|
||||
|
||||
ground_truth_file = self.ground_truths_dir / src_gt_filename
|
||||
if not ground_truth_file.exists():
|
||||
logger.warning(f"Ground truth file {ground_truth_file} does not exist, skipping")
|
||||
raise ValueError(f"Ground truth file {ground_truth_file} does not exist")
|
||||
|
||||
return doctype, prediction_file, ground_truth_file
|
||||
|
||||
def _generate_dataframes(self, rows) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
headers = ["filename", "doctype", "connector"] + self.supported_metric_names
|
||||
df = pd.DataFrame(rows, columns=headers)
|
||||
|
||||
if df.empty:
|
||||
agg_df = pd.DataFrame(columns=AGG_HEADERS)
|
||||
else:
|
||||
element_metrics_results = {}
|
||||
for metric in self.supported_metric_names:
|
||||
metric_df = df[df[metric].notnull()]
|
||||
agg_metric = metric_df[metric].agg([_mean, _stdev, _pstdev, _count]).transpose()
|
||||
if agg_metric.empty:
|
||||
element_metrics_results[metric] = pd.Series(
|
||||
data=[None, None, None, 0], index=["_mean", "_stdev", "_pstdev", "_count"]
|
||||
)
|
||||
else:
|
||||
element_metrics_results[metric] = agg_metric
|
||||
agg_df = pd.DataFrame(element_metrics_results).transpose().reset_index()
|
||||
agg_df.columns = AGG_HEADERS
|
||||
|
||||
return df, agg_df
|
||||
|
||||
|
||||
class ObjectDetectionPerClassMetricsCalculator(ObjectDetectionMetricsCalculatorBase):
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.per_class_metric_names: list[str] | None = None
|
||||
self._set_supported_metrics()
|
||||
|
||||
@property
|
||||
def supported_metric_names(self):
|
||||
if self.per_class_metric_names:
|
||||
return self.per_class_metric_names
|
||||
else:
|
||||
raise ValueError("per_class_metrics not initialized - cannot get class names")
|
||||
|
||||
@property
|
||||
def default_tsv_name(self):
|
||||
return "all-docs-object-detection-metrics-per-class.tsv"
|
||||
|
||||
@property
|
||||
def default_agg_tsv_name(self):
|
||||
return "aggregate-object-detection-metrics-per-class.tsv"
|
||||
|
||||
def _process_document(self, doc: Path) -> Optional[list]:
|
||||
"""Calculate both class-aggregated and per-class metrics for a single document.
|
||||
|
||||
Args:
|
||||
doc (Path): path to the OD dump file
|
||||
|
||||
Returns:
|
||||
tuple: a tuple of aggregated and per-class metrics for a single document
|
||||
"""
|
||||
try:
|
||||
doctype, prediction_file, ground_truth_file = self._get_paths(doc)
|
||||
except ValueError as e:
|
||||
logger.error(f"Failed to process document {doc}: {e}")
|
||||
return None
|
||||
|
||||
processor = ObjectDetectionEvalProcessor.from_json_files(
|
||||
prediction_file_path=prediction_file,
|
||||
ground_truth_file_path=ground_truth_file,
|
||||
)
|
||||
_, per_class_metrics = processor.get_metrics()
|
||||
|
||||
per_class_metrics_row = [
|
||||
ground_truth_file.stem,
|
||||
doctype,
|
||||
None, # connector
|
||||
]
|
||||
|
||||
for combined_metric_name in self.supported_metric_names:
|
||||
metric = "_".join(combined_metric_name.split("_")[:-1])
|
||||
class_name = combined_metric_name.split("_")[-1]
|
||||
class_metrics = getattr(per_class_metrics, metric)
|
||||
per_class_metrics_row.append(class_metrics[class_name])
|
||||
return per_class_metrics_row
|
||||
|
||||
def _set_supported_metrics(self):
|
||||
"""Sets the supported metrics based on the classes found in the ground truth files.
|
||||
The difference between per class and aggregated calculator is that the list of classes
|
||||
(so the metrics) bases on the contents of the GT / prediction files.
|
||||
"""
|
||||
metrics = ["f1_score", "precision", "recall", "m_ap"]
|
||||
classes = set()
|
||||
for gt_file in self._ground_truth_paths:
|
||||
gt_file_path = self.ground_truths_dir / gt_file
|
||||
with open(gt_file_path) as f:
|
||||
gt = json.load(f)
|
||||
gt_classes = gt["object_detection_classes"]
|
||||
classes.update(gt_classes)
|
||||
per_class_metric_names = []
|
||||
for metric in metrics:
|
||||
for class_name in classes:
|
||||
per_class_metric_names.append(f"{metric}_{class_name}")
|
||||
self.per_class_metric_names = sorted(per_class_metric_names)
|
||||
|
||||
|
||||
class ObjectDetectionAggregatedMetricsCalculator(ObjectDetectionMetricsCalculatorBase):
|
||||
"""Calculates object detection metrics for each document and aggregates by all classes"""
|
||||
|
||||
@property
|
||||
def supported_metric_names(self):
|
||||
return ["f1_score", "precision", "recall", "m_ap"]
|
||||
|
||||
@property
|
||||
def default_tsv_name(self):
|
||||
return "all-docs-object-detection-metrics.tsv"
|
||||
|
||||
@property
|
||||
def default_agg_tsv_name(self):
|
||||
return "aggregate-object-detection-metrics.tsv"
|
||||
|
||||
def _process_document(self, doc: Path) -> Optional[list]:
|
||||
"""Calculate both class-aggregated and per-class metrics for a single document.
|
||||
|
||||
Args:
|
||||
doc (Path): path to the OD dump file
|
||||
|
||||
Returns:
|
||||
list: a list of aggregated metrics for a single document
|
||||
"""
|
||||
try:
|
||||
doctype, prediction_file, ground_truth_file = self._get_paths(doc)
|
||||
except ValueError as e:
|
||||
logger.error(f"Failed to process document {doc}: {e}")
|
||||
return None
|
||||
|
||||
processor = ObjectDetectionEvalProcessor.from_json_files(
|
||||
prediction_file_path=prediction_file,
|
||||
ground_truth_file_path=ground_truth_file,
|
||||
)
|
||||
metrics, _ = processor.get_metrics()
|
||||
|
||||
return [
|
||||
ground_truth_file.stem,
|
||||
doctype,
|
||||
None, # connector
|
||||
] + [getattr(metrics, metric) for metric in self.supported_metric_names]
|
||||
@@ -0,0 +1,719 @@
|
||||
"""
|
||||
Implements object detection metrics: average precision, precision, recall, and f1 score.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
IOU_THRESHOLDS = torch.tensor(
|
||||
[0.5000, 0.5500, 0.6000, 0.6500, 0.7000, 0.7500, 0.8000, 0.8500, 0.9000, 0.9500]
|
||||
)
|
||||
SCORE_THRESHOLD = 0.1
|
||||
RECALL_THRESHOLDS = torch.arange(0, 1.01, 0.01)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObjectDetectionAggregatedEvaluation:
|
||||
"""Class representing a gathered class-aggregated object detection metrics"""
|
||||
|
||||
f1_score: float
|
||||
precision: float
|
||||
recall: float
|
||||
m_ap: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObjectDetectionPerClassEvaluation:
|
||||
"""Class representing a gathered object detection metrics per-class"""
|
||||
|
||||
f1_score: dict[str, float]
|
||||
precision: dict[str, float]
|
||||
recall: dict[str, float]
|
||||
m_ap: dict[str, float]
|
||||
|
||||
@classmethod
|
||||
def from_tensors(cls, ap, precision, recall, f1, class_labels):
|
||||
f1_score = {class_labels[i]: f1[i] for i in range(len(class_labels))}
|
||||
precision = {class_labels[i]: precision[i] for i in range(len(class_labels))}
|
||||
recall = {class_labels[i]: recall[i] for i in range(len(class_labels))}
|
||||
m_ap = {class_labels[i]: ap[i] for i in range(len(class_labels))}
|
||||
|
||||
return cls(f1_score, precision, recall, m_ap)
|
||||
|
||||
|
||||
class ObjectDetectionEvalProcessor:
|
||||
iou_thresholds = IOU_THRESHOLDS
|
||||
score_threshold = SCORE_THRESHOLD
|
||||
recall_thresholds = RECALL_THRESHOLDS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
document_preds: list[torch.Tensor],
|
||||
document_targets: list[torch.Tensor],
|
||||
pages_height: list[int],
|
||||
pages_width: list[int],
|
||||
class_labels: list[str],
|
||||
device: str = "cpu",
|
||||
):
|
||||
"""
|
||||
Initializes the ObjectDetection prediction and ground truth.
|
||||
|
||||
Args:
|
||||
document_preds (list): list (of length pages of document) of
|
||||
Tensors of shape (num_predictions, 6)
|
||||
format: (x1, y1, x2, y2, confidence,class_label)
|
||||
where x1,y1,x2,y2 are according to image size
|
||||
document_targets (list): list (of length pages of document) of
|
||||
Tensors of shape (num_targets, 6)
|
||||
format: (label, x1, y1, x2, y2)
|
||||
where x,y,w,h are according to image size
|
||||
pages_height (list): list of height of each page in the document
|
||||
pages_width (list): list of width of each page in the document
|
||||
class_labels (list): list of class labels
|
||||
"""
|
||||
self.device = device
|
||||
self.document_preds = [pred.to(device) for pred in document_preds]
|
||||
self.document_targets = [target.to(device) for target in document_targets]
|
||||
self.pages_height = pages_height
|
||||
self.pages_width = pages_width
|
||||
self.class_labels = class_labels
|
||||
|
||||
@classmethod
|
||||
def from_json_files(
|
||||
cls,
|
||||
prediction_file_path: Path,
|
||||
ground_truth_file_path: Path,
|
||||
) -> "ObjectDetectionEvalProcessor":
|
||||
"""
|
||||
Initializes the ObjectDetection prediction and ground truth,
|
||||
and converts the data to the required format.
|
||||
|
||||
Args:
|
||||
prediction_file_path (Path): path to json file with predictions dump from OD model
|
||||
ground_truth_file_path (Path): path to json file with OD ground truth data
|
||||
"""
|
||||
# TODO: Test after https://unstructured-ai.atlassian.net/browse/ML-92
|
||||
# is done.
|
||||
with open(prediction_file_path) as f:
|
||||
predictions_data = json.load(f)
|
||||
with open(ground_truth_file_path) as f:
|
||||
ground_truth_data = json.load(f)
|
||||
|
||||
assert sorted(predictions_data["object_detection_classes"]) == sorted(
|
||||
ground_truth_data["object_detection_classes"]
|
||||
), "Classes in predictions and ground truth do not match."
|
||||
assert len(predictions_data["pages"]) == len(
|
||||
ground_truth_data["pages"]
|
||||
), "Pages number in predictions and ground truth do not match."
|
||||
for pred_page, gt_page in zip(
|
||||
sorted(predictions_data["pages"], key=lambda p: p["number"]),
|
||||
sorted(ground_truth_data["pages"], key=lambda p: p["number"]),
|
||||
):
|
||||
assert pred_page["number"] == gt_page["number"], (
|
||||
f"Page numbers in predictions {prediction_file_path.name} "
|
||||
f"({pred_page['number']}) and ground truth {ground_truth_file_path.name} "
|
||||
f"({gt_page['number']}) do not match."
|
||||
)
|
||||
page_num = pred_page["number"]
|
||||
|
||||
# TODO: translate the bboxes instead of raising error
|
||||
assert pred_page["size"] == gt_page["size"], (
|
||||
f"Page sizes in predictions {prediction_file_path.name} "
|
||||
f"({pred_page['size'][0]} x {pred_page['size'][1]}) "
|
||||
f"and ground truth {ground_truth_file_path.name} ({gt_page['size'][0]} x "
|
||||
f"{gt_page['size'][1]}) do not match for page {page_num}."
|
||||
)
|
||||
|
||||
class_labels = predictions_data["object_detection_classes"]
|
||||
document_preds = cls._process_data(predictions_data, class_labels, prediction=True)
|
||||
document_targets = cls._process_data(ground_truth_data, class_labels)
|
||||
pages_height, pages_width = cls._parse_page_dimensions(predictions_data)
|
||||
|
||||
return cls(document_preds, document_targets, pages_height, pages_width, class_labels)
|
||||
|
||||
def get_metrics(
|
||||
self,
|
||||
) -> tuple[ObjectDetectionAggregatedEvaluation, ObjectDetectionPerClassEvaluation]:
|
||||
"""Get per document OD metrics.
|
||||
|
||||
Returns:
|
||||
tuple: Tuple of ObjectDetectionAggregatedEvaluation and
|
||||
ObjectDetectionPerClassEvaluation
|
||||
"""
|
||||
document_matchings = []
|
||||
for preds, targets, height, width in zip(
|
||||
self.document_preds, self.document_targets, self.pages_height, self.pages_width
|
||||
):
|
||||
# iterate over each page
|
||||
page_matching_tensors = self._compute_page_detection_matching(
|
||||
preds=preds,
|
||||
targets=targets,
|
||||
height=height,
|
||||
width=width,
|
||||
)
|
||||
document_matchings.append(page_matching_tensors)
|
||||
|
||||
# compute metrics for all detections and targets
|
||||
mean_ap, mean_precision, mean_recall, mean_f1 = (
|
||||
-1.0,
|
||||
-1.0,
|
||||
-1.0,
|
||||
-1.0,
|
||||
)
|
||||
|
||||
num_cls = len(self.class_labels)
|
||||
mean_ap_per_class = np.full(num_cls, np.nan)
|
||||
mean_precision_per_class = np.full(num_cls, np.nan)
|
||||
mean_recall_per_class = np.full(num_cls, np.nan)
|
||||
mean_f1_per_class = np.full(num_cls, np.nan)
|
||||
|
||||
if len(document_matchings):
|
||||
matching_info_tensors = [torch.cat(x, 0) for x in list(zip(*document_matchings))]
|
||||
|
||||
# shape (n_class, nb_iou_thresh)
|
||||
(
|
||||
ap_per_present_classes,
|
||||
precision_per_present_classes,
|
||||
recall_per_present_classes,
|
||||
f1_per_present_classes,
|
||||
present_classes,
|
||||
) = self._compute_detection_metrics(
|
||||
*matching_info_tensors,
|
||||
)
|
||||
|
||||
# Precision, recall and f1 are computed for IoU threshold range, averaged over classes
|
||||
# results before version 3.0.4 (Dec 11 2022) were computed only for smallest value
|
||||
# (i.e IoU 0.5 if metric is @0.5:0.95)
|
||||
mean_precision, mean_recall, mean_f1 = (
|
||||
precision_per_present_classes.mean(),
|
||||
recall_per_present_classes.mean(),
|
||||
f1_per_present_classes.mean(),
|
||||
)
|
||||
|
||||
# MaP is averaged over IoU thresholds and over classes
|
||||
mean_ap = ap_per_present_classes.mean()
|
||||
|
||||
# Fill array of per-class AP scores with values for classes that were present in the
|
||||
# dataset
|
||||
ap_per_class = ap_per_present_classes.mean(1)
|
||||
precision_per_class = precision_per_present_classes.mean(1)
|
||||
recall_per_class = recall_per_present_classes.mean(1)
|
||||
f1_per_class = f1_per_present_classes.mean(1)
|
||||
for i, class_index in enumerate(present_classes):
|
||||
mean_ap_per_class[class_index] = float(ap_per_class[i])
|
||||
|
||||
mean_precision_per_class[class_index] = float(precision_per_class[i])
|
||||
mean_recall_per_class[class_index] = float(recall_per_class[i])
|
||||
mean_f1_per_class[class_index] = float(f1_per_class[i])
|
||||
|
||||
od_per_class_evaluation = ObjectDetectionPerClassEvaluation.from_tensors(
|
||||
ap=mean_ap_per_class,
|
||||
precision=mean_precision_per_class,
|
||||
recall=mean_recall_per_class,
|
||||
f1=mean_f1_per_class,
|
||||
class_labels=self.class_labels,
|
||||
)
|
||||
|
||||
od_evaluation = ObjectDetectionAggregatedEvaluation(
|
||||
f1_score=float(mean_f1),
|
||||
precision=float(mean_precision),
|
||||
recall=float(mean_recall),
|
||||
m_ap=float(mean_ap),
|
||||
)
|
||||
|
||||
return od_evaluation, od_per_class_evaluation
|
||||
|
||||
@staticmethod
|
||||
def _parse_page_dimensions(data: dict) -> tuple[list, list]:
|
||||
"""
|
||||
Process the page dimensions from the json file to the required format.
|
||||
"""
|
||||
pages_height = []
|
||||
pages_width = []
|
||||
for page in data["pages"]:
|
||||
pages_height.append(page["size"]["height"])
|
||||
pages_width.append(page["size"]["width"])
|
||||
return pages_height, pages_width
|
||||
|
||||
@staticmethod
|
||||
def _process_data(data: dict, class_labels, prediction: bool = False) -> list[dict]:
|
||||
"""
|
||||
Process the elements from the json file to the required format.
|
||||
"""
|
||||
pages_list = []
|
||||
for page in data["pages"]:
|
||||
page_elements = []
|
||||
for element in page["elements"]:
|
||||
# Extract coordinates, confidence, and class label from each prediction
|
||||
class_label = element["type"]
|
||||
class_idx = class_labels.index(class_label)
|
||||
x1, y1, x2, y2 = element["bbox"]
|
||||
if prediction:
|
||||
confidence = element["prob"]
|
||||
page_elements.append([x1, y1, x2, y2, confidence, class_idx])
|
||||
else:
|
||||
page_elements.append([class_idx, x1, y1, x2, y2])
|
||||
page_tensor = torch.tensor(page_elements)
|
||||
pages_list.append(page_tensor)
|
||||
|
||||
return pages_list
|
||||
|
||||
@staticmethod
|
||||
def _get_top_k_idx_per_cls(
|
||||
preds_scores: torch.Tensor, preds_cls: torch.Tensor, top_k: int
|
||||
) -> torch.Tensor:
|
||||
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
||||
"""
|
||||
Get the indexes of all the top k predictions for every class
|
||||
|
||||
Args:
|
||||
preds_scores: The confidence scores, vector of shape (n_pred)
|
||||
preds_cls: The predicted class, vector of shape (n_pred)
|
||||
top_k: Number of predictions to keep per class, ordered by confidence score
|
||||
|
||||
Returns:
|
||||
top_k_idx: Indexes of the top k predictions. length <= (k * n_unique_class)
|
||||
"""
|
||||
n_unique_cls = torch.max(preds_cls)
|
||||
mask = preds_cls.view(-1, 1) == torch.arange(
|
||||
n_unique_cls + 1, device=preds_scores.device
|
||||
).view(1, -1)
|
||||
preds_scores_per_cls = preds_scores.view(-1, 1) * mask
|
||||
|
||||
sorted_scores_per_cls, sorting_idx = preds_scores_per_cls.sort(0, descending=True)
|
||||
idx_with_satisfying_scores = sorted_scores_per_cls[:top_k, :].nonzero(as_tuple=False)
|
||||
top_k_idx = sorting_idx[idx_with_satisfying_scores.split(1, dim=1)]
|
||||
return top_k_idx.view(-1)
|
||||
|
||||
@staticmethod
|
||||
def _change_bbox_bounds_for_image_size(
|
||||
boxes: np.ndarray, img_shape: tuple[int, int]
|
||||
) -> np.ndarray:
|
||||
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
||||
"""
|
||||
Clips bboxes to image boundaries.
|
||||
|
||||
Args:
|
||||
bboxes: Input bounding boxes in XYXY format of [..., 4] shape
|
||||
img_shape: Image shape (height, width).
|
||||
Returns:
|
||||
clipped_boxes: Clipped bboxes in XYXY format of [..., 4] shape
|
||||
"""
|
||||
boxes[..., [0, 2]] = boxes[..., [0, 2]].clip(min=0, max=img_shape[1])
|
||||
boxes[..., [1, 3]] = boxes[..., [1, 3]].clip(min=0, max=img_shape[0])
|
||||
return boxes
|
||||
|
||||
@staticmethod
|
||||
def _box_iou(box1: torch.Tensor, box2: torch.Tensor) -> torch.Tensor:
|
||||
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
||||
"""
|
||||
Return intersection-over-union (Jaccard index) of boxes.
|
||||
Both sets of boxes are expected to be in (x1, y1, x2, y2) format.
|
||||
|
||||
Args:
|
||||
box1: Tensor of shape [N, 4]
|
||||
box2: Tensor of shape [M, 4]
|
||||
|
||||
Returns:
|
||||
iou: Tensor of shape [N, M]: the NxM matrix containing the pairwise IoU values
|
||||
for every element in boxes1 and boxes2
|
||||
"""
|
||||
|
||||
def box_area(box):
|
||||
# box = 4xn
|
||||
return (box[2] - box[0]) * (box[3] - box[1])
|
||||
|
||||
area1 = box_area(box1.T)
|
||||
area2 = box_area(box2.T)
|
||||
|
||||
# inter(N,M) = (rb(N,M,2) - lt(N,M,2)).clamp(0).prod(2)
|
||||
inter = (
|
||||
(torch.min(box1[:, None, 2:], box2[:, 2:]) - torch.max(box1[:, None, :2], box2[:, :2]))
|
||||
.clamp(0)
|
||||
.prod(2)
|
||||
)
|
||||
return inter / (area1[:, None] + area2 - inter) # iou = inter / (area1 + area2 - inter)
|
||||
|
||||
def _compute_targets(
|
||||
self,
|
||||
preds_box_xyxy: torch.Tensor,
|
||||
preds_cls: torch.Tensor,
|
||||
targets_box_xyxy: torch.Tensor,
|
||||
targets_cls: torch.Tensor,
|
||||
preds_matched: torch.Tensor,
|
||||
targets_matched: torch.Tensor,
|
||||
preds_idx_to_use: torch.Tensor,
|
||||
iou_thresholds: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
||||
"""
|
||||
Computes the matching targets based on IoU for regular scenarios.
|
||||
|
||||
Args:
|
||||
preds_box_xyxy: (torch.Tensor) Predicted bounding boxes in XYXY format.
|
||||
preds_cls: (torch.Tensor) Predicted classes.
|
||||
targets_box_xyxy: (torch.Tensor) Target bounding boxes in XYXY format.
|
||||
targets_cls: (torch.Tensor) Target classes.
|
||||
preds_matched: (torch.Tensor) Tensor indicating which predictions are matched.
|
||||
targets_matched: (torch.Tensor) Tensor indicating which targets are matched.
|
||||
preds_idx_to_use: (torch.Tensor) Indices of predictions to use.
|
||||
|
||||
Returns:
|
||||
targets: Computed matching targets.
|
||||
"""
|
||||
# shape = (n_preds x n_targets)
|
||||
iou = self._box_iou(preds_box_xyxy[preds_idx_to_use], targets_box_xyxy)
|
||||
|
||||
# Fill IoU values at index (i, j) with 0 when the prediction (i) and target(j)
|
||||
# are of different class
|
||||
# Filling with 0 is equivalent to ignore these values
|
||||
# since with want IoU > iou_threshold > 0
|
||||
cls_mismatch = preds_cls[preds_idx_to_use].view(-1, 1) != targets_cls.view(1, -1)
|
||||
iou[cls_mismatch] = 0
|
||||
|
||||
# The matching priority is first detection confidence and then IoU value.
|
||||
# The detection is already sorted by confidence in NMS,
|
||||
# so here for each prediction we order the targets by iou.
|
||||
sorted_iou, target_sorted = iou.sort(descending=True, stable=True)
|
||||
|
||||
# Only iterate over IoU values higher than min threshold to speed up the process
|
||||
for pred_selected_i, target_sorted_i in (sorted_iou > iou_thresholds[0]).nonzero(
|
||||
as_tuple=False
|
||||
):
|
||||
# pred_selected_i and target_sorted_i are relative to filters/sorting,
|
||||
# so we extract their absolute indexes
|
||||
pred_i = preds_idx_to_use[pred_selected_i]
|
||||
target_i = target_sorted[pred_selected_i, target_sorted_i]
|
||||
|
||||
# Vector[j], True when IoU(pred_i, target_i) is above the (j)th threshold
|
||||
is_iou_above_threshold = sorted_iou[pred_selected_i, target_sorted_i] > iou_thresholds
|
||||
|
||||
# Vector[j], True when both pred_i and target_i are not matched yet
|
||||
# for the (j)th threshold
|
||||
are_candidates_free = torch.logical_and(
|
||||
~preds_matched[pred_i, :], ~targets_matched[target_i, :]
|
||||
)
|
||||
|
||||
# Vector[j], True when (pred_i, target_i) can be matched for the (j)th threshold
|
||||
are_candidates_good = torch.logical_and(is_iou_above_threshold, are_candidates_free)
|
||||
|
||||
# For every threshold (j) where target_i and pred_i can be matched together
|
||||
# ( are_candidates_good[j]==True )
|
||||
# fill the matching placeholders with True
|
||||
targets_matched[target_i, are_candidates_good] = True
|
||||
preds_matched[pred_i, are_candidates_good] = True
|
||||
|
||||
# When all the targets are matched with a prediction for every IoU Threshold, stop.
|
||||
if targets_matched.all():
|
||||
break
|
||||
|
||||
return preds_matched
|
||||
|
||||
def _compute_page_detection_matching(
|
||||
self,
|
||||
preds: torch.Tensor,
|
||||
targets: torch.Tensor,
|
||||
height: int,
|
||||
width: int,
|
||||
top_k: int = 100,
|
||||
return_on_cpu: bool = True,
|
||||
) -> tuple:
|
||||
# Adapted from: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
||||
"""
|
||||
Match predictions (NMS output) and the targets (ground truth) with respect to metric
|
||||
and confidence score for a given image.
|
||||
|
||||
Args:
|
||||
preds: Tensor of shape (num_img_predictions, 6)
|
||||
format: (x1, y1, x2, y2, confidence, class_label)
|
||||
where x1,y1,x2,y2 are according to image size
|
||||
targets: targets for this image of shape (num_img_targets, 5)
|
||||
format: (label, x1, y1, x2, y2)
|
||||
where x1,y1,x2,y2 are according to image size
|
||||
height: dimensions of the image
|
||||
width: dimensions of the image
|
||||
top_k: Number of predictions to keep per class, ordered by confidence score
|
||||
return_on_cpu: If True, the output will be returned on "CPU", otherwise it will be
|
||||
returned on "device"
|
||||
|
||||
Returns:
|
||||
preds_matched: Tensor of shape (num_img_predictions, n_thresholds)
|
||||
True when prediction (i) is matched with a target with respect to
|
||||
the (j)th threshold
|
||||
preds_to_ignore: Tensor of shape (num_img_predictions, n_thresholds)
|
||||
True when prediction (i) is matched with a crowd target with
|
||||
respect to the (j)th threshold
|
||||
preds_scores: Tensor of shape (num_img_predictions),
|
||||
confidence score for every prediction
|
||||
preds_cls: Tensor of shape (num_img_predictions),
|
||||
predicted class for every prediction
|
||||
targets_cls: Tensor of shape (num_img_targets),
|
||||
ground truth class for every target
|
||||
"""
|
||||
thresholds = self.iou_thresholds.to(device=self.device)
|
||||
num_thresholds = len(thresholds)
|
||||
|
||||
if preds is None or len(preds) == 0:
|
||||
preds_matched = torch.zeros((0, num_thresholds), dtype=torch.bool, device=self.device)
|
||||
preds_to_ignore = torch.zeros((0, num_thresholds), dtype=torch.bool, device=self.device)
|
||||
preds_scores = torch.tensor([], dtype=torch.float32, device=self.device)
|
||||
preds_cls = torch.tensor([], dtype=torch.float32, device=self.device)
|
||||
targets_cls = targets[:, 0].to(device=self.device)
|
||||
return preds_matched, preds_to_ignore, preds_scores, preds_cls, targets_cls
|
||||
|
||||
preds_matched = torch.zeros(
|
||||
len(preds), num_thresholds, dtype=torch.bool, device=self.device
|
||||
)
|
||||
targets_matched = torch.zeros(
|
||||
len(targets), num_thresholds, dtype=torch.bool, device=self.device
|
||||
)
|
||||
preds_to_ignore = torch.zeros(
|
||||
len(preds), num_thresholds, dtype=torch.bool, device=self.device
|
||||
)
|
||||
|
||||
preds_cls, preds_box, preds_scores = preds[:, -1], preds[:, 0:4], preds[:, 4]
|
||||
targets_cls, targets_box = targets[:, 0], targets[:, 1:5]
|
||||
|
||||
# Ignore all but the predictions that were top_k for their class
|
||||
preds_idx_to_use = self._get_top_k_idx_per_cls(preds_scores, preds_cls, top_k)
|
||||
preds_to_ignore[:, :] = True
|
||||
preds_to_ignore[preds_idx_to_use] = False
|
||||
|
||||
if len(targets) > 0: # or len(crowd_targets) > 0:
|
||||
self._change_bbox_bounds_for_image_size(preds, (height, width))
|
||||
|
||||
preds_matched = self._compute_targets(
|
||||
preds_box,
|
||||
preds_cls,
|
||||
targets_box,
|
||||
targets_cls,
|
||||
preds_matched,
|
||||
targets_matched,
|
||||
preds_idx_to_use,
|
||||
thresholds,
|
||||
)
|
||||
|
||||
return preds_matched, preds_to_ignore, preds_scores, preds_cls, targets_cls
|
||||
|
||||
def _compute_detection_metrics(
|
||||
self,
|
||||
preds_matched: torch.Tensor,
|
||||
preds_to_ignore: torch.Tensor,
|
||||
preds_scores: torch.Tensor,
|
||||
preds_cls: torch.Tensor,
|
||||
targets_cls: torch.Tensor,
|
||||
) -> tuple:
|
||||
# Adapted from: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
||||
"""
|
||||
Compute the list of precision, recall, MaP and f1 for every class.
|
||||
|
||||
Args:
|
||||
preds_matched: Tensor of shape (num_predictions, n_iou_thresholds)
|
||||
True when prediction (i) is matched with a target with respect
|
||||
to the (j)th IoU threshold
|
||||
preds_to_ignore Tensor of shape (num_predictions, n_iou_thresholds)
|
||||
True when prediction (i) is matched with a crowd target with
|
||||
respect to the (j)th IoU threshold
|
||||
preds_scores: Tensor of shape (num_predictions),
|
||||
confidence score for every prediction
|
||||
preds_cls: Tensor of shape (num_predictions),
|
||||
predicted class for every prediction
|
||||
targets_cls: Tensor of shape (num_targets),
|
||||
ground truth class for every target box to be detected
|
||||
|
||||
Returns:
|
||||
ap, precision, recall, f1: Tensors of shape (n_class, nb_iou_thrs)
|
||||
unique_classes: Vector with all unique target classes
|
||||
"""
|
||||
|
||||
preds_matched, preds_to_ignore = preds_matched.to(self.device), preds_to_ignore.to(
|
||||
self.device
|
||||
)
|
||||
preds_scores, preds_cls, targets_cls = (
|
||||
preds_scores.to(self.device),
|
||||
preds_cls.to(self.device),
|
||||
targets_cls.to(self.device),
|
||||
)
|
||||
|
||||
recall_thresholds = self.recall_thresholds.to(self.device)
|
||||
score_threshold = self.score_threshold
|
||||
|
||||
unique_classes = torch.unique(targets_cls).long()
|
||||
|
||||
n_class, nb_iou_thrs = len(unique_classes), preds_matched.shape[-1]
|
||||
|
||||
ap = torch.zeros((n_class, nb_iou_thrs), device=self.device)
|
||||
precision = torch.zeros((n_class, nb_iou_thrs), device=self.device)
|
||||
recall = torch.zeros((n_class, nb_iou_thrs), device=self.device)
|
||||
|
||||
for cls_i, class_value in enumerate(unique_classes):
|
||||
cls_preds_idx, cls_targets_idx = (preds_cls == class_value), (
|
||||
targets_cls == class_value
|
||||
)
|
||||
(
|
||||
cls_ap,
|
||||
cls_precision,
|
||||
cls_recall,
|
||||
) = self._compute_detection_metrics_per_cls(
|
||||
preds_matched=preds_matched[cls_preds_idx],
|
||||
preds_to_ignore=preds_to_ignore[cls_preds_idx],
|
||||
preds_scores=preds_scores[cls_preds_idx],
|
||||
n_targets=cls_targets_idx.sum(),
|
||||
recall_thresholds=recall_thresholds,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
ap[cls_i, :] = cls_ap
|
||||
precision[cls_i, :] = cls_precision
|
||||
recall[cls_i, :] = cls_recall
|
||||
|
||||
f1 = 2 * precision * recall / (precision + recall + 1e-16)
|
||||
return ap, precision, recall, f1, unique_classes
|
||||
|
||||
def _compute_detection_metrics_per_cls(
|
||||
self,
|
||||
preds_matched: torch.Tensor,
|
||||
preds_to_ignore: torch.Tensor,
|
||||
preds_scores: torch.Tensor,
|
||||
n_targets: int,
|
||||
recall_thresholds: torch.Tensor,
|
||||
score_threshold: float,
|
||||
):
|
||||
# Adapted from: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
||||
"""
|
||||
Compute the list of precision, recall and MaP of a given class for every recall threshold.
|
||||
|
||||
Args:
|
||||
preds_matched: Tensor of shape (num_predictions, n_thresholds)
|
||||
True when prediction (i) is matched with a target
|
||||
with respect to the(j)th threshold
|
||||
preds_to_ignore Tensor of shape (num_predictions, n_thresholds)
|
||||
True when prediction (i) is matched with a crowd target
|
||||
with respect to the (j)th threshold
|
||||
preds_scores: Tensor of shape (num_predictions),
|
||||
confidence score for every prediction
|
||||
n_targets: Number of target boxes of this class
|
||||
recall_thresholds: Tensor of shape (max_n_rec_thresh)
|
||||
list of recall thresholds used to compute MaP
|
||||
score_threshold: Minimum confidence score to consider a prediction
|
||||
for the computation of precision and recall (not MaP)
|
||||
|
||||
Returns:
|
||||
ap, precision, recall: Tensors of shape (nb_thrs)
|
||||
"""
|
||||
|
||||
nb_iou_thrs = preds_matched.shape[-1]
|
||||
|
||||
tps = preds_matched
|
||||
fps = torch.logical_and(
|
||||
torch.logical_not(preds_matched), torch.logical_not(preds_to_ignore)
|
||||
)
|
||||
|
||||
if len(tps) == 0:
|
||||
return (
|
||||
torch.zeros(nb_iou_thrs, device=self.device),
|
||||
torch.zeros(nb_iou_thrs, device=self.device),
|
||||
torch.zeros(nb_iou_thrs, device=self.device),
|
||||
)
|
||||
|
||||
# Sort by decreasing score
|
||||
dtype = (
|
||||
torch.uint8
|
||||
if preds_scores.is_cuda and preds_scores.dtype is torch.bool
|
||||
else preds_scores.dtype
|
||||
)
|
||||
sort_ind = torch.argsort(preds_scores.to(dtype), descending=True)
|
||||
tps = tps[sort_ind, :]
|
||||
fps = fps[sort_ind, :]
|
||||
preds_scores = preds_scores[sort_ind].contiguous()
|
||||
|
||||
# Rolling sum over the predictions
|
||||
rolling_tps = torch.cumsum(tps, axis=0, dtype=torch.float)
|
||||
rolling_fps = torch.cumsum(fps, axis=0, dtype=torch.float)
|
||||
|
||||
rolling_recalls = rolling_tps / n_targets
|
||||
rolling_precisions = rolling_tps / (
|
||||
rolling_tps + rolling_fps + torch.finfo(torch.float64).eps
|
||||
)
|
||||
|
||||
# Reversed cummax to only have decreasing values
|
||||
rolling_precisions = rolling_precisions.flip(0).cummax(0).values.flip(0)
|
||||
|
||||
# ==================
|
||||
# RECALL & PRECISION
|
||||
|
||||
# We want the rolling precision/recall at index i so that:
|
||||
# preds_scores[i-1] >= score_threshold > preds_scores[i]
|
||||
# Note: torch.searchsorted works on increasing sequence and preds_scores is decreasing,
|
||||
# so we work with "-"
|
||||
# Note2: right=True due to negation
|
||||
lowest_score_above_threshold = torch.searchsorted(
|
||||
-preds_scores, -score_threshold, right=True
|
||||
)
|
||||
|
||||
if (
|
||||
lowest_score_above_threshold == 0
|
||||
): # Here score_threshold > preds_scores[0], so no pred is above the threshold
|
||||
recall = torch.zeros(nb_iou_thrs, device=self.device)
|
||||
precision = torch.zeros(
|
||||
nb_iou_thrs, device=self.device
|
||||
) # the precision is not really defined when no pred but we need to give it a value
|
||||
else:
|
||||
recall = rolling_recalls[lowest_score_above_threshold - 1]
|
||||
precision = rolling_precisions[lowest_score_above_threshold - 1]
|
||||
|
||||
# ==================
|
||||
# AVERAGE PRECISION
|
||||
|
||||
# shape = (nb_iou_thrs, n_recall_thresholds)
|
||||
recall_thresholds = recall_thresholds.view(1, -1).repeat(nb_iou_thrs, 1)
|
||||
|
||||
# We want the index i so that:
|
||||
# rolling_recalls[i-1] < recall_thresholds[k] <= rolling_recalls[i]
|
||||
# Note: when recall_thresholds[k] > max(rolling_recalls), i = len(rolling_recalls)
|
||||
# Note2: we work with transpose (.T) to apply torch.searchsorted on first dim
|
||||
# instead of the last one
|
||||
recall_threshold_idx = torch.searchsorted(
|
||||
rolling_recalls.T.contiguous(), recall_thresholds, right=False
|
||||
).T
|
||||
|
||||
# When recall_thresholds[k] > max(rolling_recalls),
|
||||
# rolling_precisions[i] is not defined, and we want precision = 0
|
||||
rolling_precisions = torch.cat(
|
||||
(rolling_precisions, torch.zeros(1, nb_iou_thrs, device=self.device)), dim=0
|
||||
)
|
||||
|
||||
# shape = (n_recall_thresholds, nb_iou_thrs)
|
||||
sampled_precision_points = torch.gather(
|
||||
input=rolling_precisions, index=recall_threshold_idx, dim=0
|
||||
)
|
||||
|
||||
# Average over the recall_thresholds
|
||||
ap = sampled_precision_points.mean(0)
|
||||
|
||||
return ap, precision, recall
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from dataclasses import asdict
|
||||
|
||||
# Example usage
|
||||
prediction_file_paths = [Path("pths/to/predictions.json"), Path("pths/to/predictions2.json")]
|
||||
ground_truth_file_paths = [
|
||||
Path("pths/to/ground_truth.json"),
|
||||
Path("pths/to/ground_truth2.json"),
|
||||
]
|
||||
|
||||
for prediction_file_path, ground_truth_file_path in zip(
|
||||
prediction_file_paths, ground_truth_file_paths
|
||||
):
|
||||
eval_processor = ObjectDetectionEvalProcessor.from_json_files(
|
||||
prediction_file_path, ground_truth_file_path
|
||||
)
|
||||
|
||||
metrics, per_class_metrics = eval_processor.get_metrics()
|
||||
print(f"Metrics for {ground_truth_file_path.name}:\n{asdict(metrics)}")
|
||||
print(f"Per class Metrics for {ground_truth_file_path.name}:\n{asdict(per_class_metrics)}")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,180 @@
|
||||
import difflib
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from unstructured_inference.models.eval import compare_contents_as_df
|
||||
|
||||
|
||||
class TableAlignment:
|
||||
def __init__(self, cutoff: float = 0.8):
|
||||
self.cutoff = cutoff
|
||||
|
||||
@staticmethod
|
||||
def get_content_in_tables(table_data: List[List[Dict[str, Any]]]) -> List[str]:
|
||||
# Replace below docstring with google-style docstring
|
||||
"""Extracts and concatenates the content of cells from each table in a list of tables.
|
||||
|
||||
Args:
|
||||
table_data: A list of tables, each table being a list of cell data dictionaries.
|
||||
|
||||
Returns:
|
||||
List of strings where each string represents the concatenated content of one table.
|
||||
"""
|
||||
return [" ".join([d["content"] for d in td if "content" in d]) for td in table_data]
|
||||
|
||||
@staticmethod
|
||||
def get_table_level_alignment(
|
||||
predicted_table_data: List[List[Dict[str, Any]]],
|
||||
ground_truth_table_data: List[List[Dict[str, Any]]],
|
||||
) -> List[int]:
|
||||
"""Compares predicted table data with ground truth data to find the best
|
||||
matching table index for each predicted table.
|
||||
|
||||
Args:
|
||||
predicted_table_data: A list of predicted tables.
|
||||
ground_truth_table_data: A list of ground truth tables.
|
||||
|
||||
Returns:
|
||||
A list of indices indicating the best match in the ground truth for
|
||||
each predicted table.
|
||||
|
||||
"""
|
||||
ground_truth_texts = TableAlignment.get_content_in_tables(ground_truth_table_data)
|
||||
matched_indices = []
|
||||
for td in predicted_table_data:
|
||||
reference = TableAlignment.get_content_in_tables([td])[0]
|
||||
matches = difflib.get_close_matches(reference, ground_truth_texts, cutoff=0.1, n=1)
|
||||
matched_indices.append(ground_truth_texts.index(matches[0]) if matches else -1)
|
||||
return matched_indices
|
||||
|
||||
@staticmethod
|
||||
def _zip_to_dataframe(table_data: List[Dict[str, Any]]) -> pd.DataFrame:
|
||||
df = pd.DataFrame(table_data, columns=["row_index", "col_index", "content"])
|
||||
df = df.set_index("row_index")
|
||||
df["col_index"] = df["col_index"].astype(str)
|
||||
return df
|
||||
|
||||
@staticmethod
|
||||
def get_element_level_alignment(
|
||||
predicted_table_data: List[List[Dict[str, Any]]],
|
||||
ground_truth_table_data: List[List[Dict[str, Any]]],
|
||||
matched_indices: List[int],
|
||||
cutoff: float = 0.8,
|
||||
) -> Dict[str, float]:
|
||||
"""Aligns elements of the predicted tables with the ground truth tables at the cell level.
|
||||
|
||||
Args:
|
||||
predicted_table_data: A list of predicted tables.
|
||||
ground_truth_table_data: A list of ground truth tables.
|
||||
matched_indices: Indices of the best matching ground truth table for each predicted table.
|
||||
cutoff: The cutoff value for the close matches.
|
||||
|
||||
Returns:
|
||||
A dictionary with column and row alignment accuracies.
|
||||
|
||||
"""
|
||||
content_diff_cols = []
|
||||
content_diff_rows = []
|
||||
col_index_acc = []
|
||||
row_index_acc = []
|
||||
|
||||
for idx, td in zip(matched_indices, predicted_table_data):
|
||||
if idx == -1:
|
||||
content_diff_cols.append(0)
|
||||
content_diff_rows.append(0)
|
||||
col_index_acc.append(0)
|
||||
row_index_acc.append(0)
|
||||
continue
|
||||
ground_truth_td = ground_truth_table_data[idx]
|
||||
|
||||
# Get row and col content accuracy
|
||||
predict_table_df = TableAlignment._zip_to_dataframe(td)
|
||||
ground_truth_table_df = TableAlignment._zip_to_dataframe(ground_truth_td)
|
||||
|
||||
table_content_diff = compare_contents_as_df(
|
||||
ground_truth_table_df.fillna(""),
|
||||
predict_table_df.fillna(""),
|
||||
)
|
||||
content_diff_cols.append(table_content_diff["by_col_token_ratio"])
|
||||
content_diff_rows.append(table_content_diff["by_row_token_ratio"])
|
||||
|
||||
aligned_element_col_count = 0
|
||||
aligned_element_row_count = 0
|
||||
total_element_count = 0
|
||||
# Get row and col index accuracy
|
||||
ground_truth_td_contents_list = [gtd["content"].lower() for gtd in ground_truth_td]
|
||||
used_indices = set()
|
||||
indices_tuple_pairs = []
|
||||
for td_ele in td:
|
||||
content = td_ele["content"].lower()
|
||||
row_index = td_ele["row_index"]
|
||||
col_idx = td_ele["col_index"]
|
||||
|
||||
matches = difflib.get_close_matches(
|
||||
content,
|
||||
ground_truth_td_contents_list,
|
||||
cutoff=cutoff,
|
||||
n=1,
|
||||
)
|
||||
# BUG FIX: the previous matched_idx will only output the first matched index if
|
||||
# the match has duplicates in the
|
||||
# ground_truth_td_contents_list, the current fix will output its correspondence idx
|
||||
# once matching is exhausted, it will go back search again the same fashion
|
||||
matching_indices = []
|
||||
if matches != []:
|
||||
b_indices = [
|
||||
i
|
||||
for i, b_string in enumerate(ground_truth_td_contents_list)
|
||||
if b_string == matches[0] and i not in used_indices
|
||||
]
|
||||
if not b_indices:
|
||||
# If all indices are used, reset used_indices and use the first index
|
||||
used_indices.clear()
|
||||
b_indices = [
|
||||
i
|
||||
for i, b_string in enumerate(ground_truth_td_contents_list)
|
||||
if b_string == matches[0] and i not in used_indices
|
||||
]
|
||||
matching_index = b_indices[0]
|
||||
matching_indices.append(matching_index)
|
||||
used_indices.add(matching_index)
|
||||
else:
|
||||
matching_indices = [-1]
|
||||
matched_idx = matching_indices[0]
|
||||
if matched_idx >= 0:
|
||||
gt_row_index = ground_truth_td[matched_idx]["row_index"]
|
||||
gt_col_index = ground_truth_td[matched_idx]["col_index"]
|
||||
indices_tuple_pairs.append(((row_index, col_idx), (gt_row_index, gt_col_index)))
|
||||
|
||||
for indices_tuple_pair in indices_tuple_pairs:
|
||||
if indices_tuple_pair[0][0] == indices_tuple_pair[1][0]:
|
||||
aligned_element_row_count += 1
|
||||
if indices_tuple_pair[0][1] == indices_tuple_pair[1][1]:
|
||||
aligned_element_col_count += 1
|
||||
total_element_count += 1
|
||||
|
||||
table_col_index_acc = 0
|
||||
table_row_index_acc = 0
|
||||
if total_element_count > 0:
|
||||
table_col_index_acc = round(aligned_element_col_count / total_element_count, 2)
|
||||
table_row_index_acc = round(aligned_element_row_count / total_element_count, 2)
|
||||
|
||||
col_index_acc.append(table_col_index_acc)
|
||||
row_index_acc.append(table_row_index_acc)
|
||||
|
||||
not_found_gt_table_indexes = [
|
||||
id for id in range(len(ground_truth_table_data)) if id not in matched_indices
|
||||
]
|
||||
for _ in not_found_gt_table_indexes:
|
||||
content_diff_cols.append(0)
|
||||
content_diff_rows.append(0)
|
||||
col_index_acc.append(0)
|
||||
row_index_acc.append(0)
|
||||
|
||||
return {
|
||||
"col_index_acc": round(np.mean(col_index_acc), 2),
|
||||
"row_index_acc": round(np.mean(row_index_acc), 2),
|
||||
"col_content_acc": round(np.mean(content_diff_cols) / 100.0, 2),
|
||||
"row_content_acc": round(np.mean(content_diff_rows) / 100.0, 2),
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
The purpose of this script is to create a comprehensive metric for table evaluation
|
||||
1. Verify table identification.
|
||||
a. Concatenate all text in the table and ground truth.
|
||||
b. Calculate the difference to find the closest matches.
|
||||
c. If contents are too different, mark as a failure.
|
||||
|
||||
2. For each identified table:
|
||||
a. Align elements at the level of individual elements.
|
||||
b. Match elements by text.
|
||||
c. Determine indexes for both predicted and actual data.
|
||||
d. Compare index tuples at column and row levels to assess content shifts.
|
||||
e. Compare the token orders by flattened along column and row levels
|
||||
f. Note: Imperfect HTML is acceptable unless it impedes parsing,
|
||||
in which case the table is considered failed.
|
||||
|
||||
Example
|
||||
python table_eval.py \
|
||||
--prediction_file "model_output.pdf.json" \
|
||||
--ground_truth_file "ground_truth.pdf.json"
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
|
||||
from unstructured.metrics.table.table_alignment import TableAlignment
|
||||
from unstructured.metrics.table.table_extraction import (
|
||||
extract_and_convert_tables_from_ground_truth,
|
||||
extract_and_convert_tables_from_prediction,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableEvaluation:
|
||||
"""Class representing a gathered table metrics."""
|
||||
|
||||
total_tables: int
|
||||
total_predicted_tables: int
|
||||
table_level_acc: float
|
||||
table_detection_recall: float
|
||||
table_detection_precision: float
|
||||
table_detection_f1: float
|
||||
element_col_level_index_acc: float
|
||||
element_row_level_index_acc: float
|
||||
element_col_level_content_acc: float
|
||||
element_row_level_content_acc: float
|
||||
|
||||
@property
|
||||
def composite_structure_acc(self) -> float:
|
||||
return (
|
||||
self.element_col_level_index_acc
|
||||
+ self.element_row_level_index_acc
|
||||
+ (self.element_col_level_content_acc + self.element_row_level_content_acc) / 2
|
||||
) / 3
|
||||
|
||||
|
||||
def table_level_acc(predicted_table_data, ground_truth_table_data, matched_indices):
|
||||
"""computes for each predicted table its accurary compared to ground truth.
|
||||
|
||||
The accuracy is defined as the SequenceMatcher.ratio() between those two strings. If a
|
||||
prediction does not have a matched ground truth its accuracy is 0
|
||||
"""
|
||||
score = np.zeros((len(matched_indices),))
|
||||
ground_truth_text = TableAlignment.get_content_in_tables(ground_truth_table_data)
|
||||
for idx, predicted in enumerate(predicted_table_data):
|
||||
matched_idx = matched_indices[idx]
|
||||
if matched_idx == -1:
|
||||
# false positive; default score 0
|
||||
continue
|
||||
score[idx] = difflib.SequenceMatcher(
|
||||
None,
|
||||
TableAlignment.get_content_in_tables([predicted])[0],
|
||||
ground_truth_text[matched_idx],
|
||||
).ratio()
|
||||
return score
|
||||
|
||||
|
||||
def _count_predicted_tables(matched_indices: List[int]) -> int:
|
||||
"""Counts the number of predicted tables that have a corresponding match in the ground truth.
|
||||
|
||||
Args:
|
||||
matched_indices: List of indices indicating matches between predicted
|
||||
and ground truth tables.
|
||||
|
||||
Returns:
|
||||
The count of matched predicted tables.
|
||||
|
||||
"""
|
||||
return sum(1 for idx in matched_indices if idx >= 0)
|
||||
|
||||
|
||||
def calculate_table_detection_metrics(
|
||||
matched_indices: list[int], ground_truth_tables_number: int
|
||||
) -> tuple[float, float, float]:
|
||||
"""
|
||||
Calculate the table detection metrics: recall, precision, and f1 score.
|
||||
Args:
|
||||
matched_indices:
|
||||
List of indices indicating matches between predicted and ground truth tables
|
||||
For example: matched_indices[i] = j means that the
|
||||
i-th predicted table is matched with the j-th ground truth table.
|
||||
ground_truth_tables_number: the number of ground truth tables.
|
||||
|
||||
Returns:
|
||||
Tuple of recall, precision, and f1 scores
|
||||
"""
|
||||
predicted_tables_number = len(matched_indices)
|
||||
|
||||
matched_set = set(matched_indices)
|
||||
if -1 in matched_set:
|
||||
matched_set.remove(-1)
|
||||
|
||||
true_positive = len(matched_set)
|
||||
false_positive = predicted_tables_number - true_positive
|
||||
positive = ground_truth_tables_number
|
||||
|
||||
recall = true_positive / positive if positive > 0 else 0
|
||||
precision = (
|
||||
true_positive / (true_positive + false_positive)
|
||||
if true_positive + false_positive > 0
|
||||
else 0
|
||||
)
|
||||
f1 = 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0
|
||||
|
||||
return recall, precision, f1
|
||||
|
||||
|
||||
class TableEvalProcessor:
|
||||
def __init__(
|
||||
self,
|
||||
prediction: List[Dict[str, Any]],
|
||||
ground_truth: List[Dict[str, Any]],
|
||||
cutoff: float = 0.8,
|
||||
source_type: str = "html",
|
||||
):
|
||||
"""
|
||||
Initializes the TableEvalProcessor prediction and ground truth.
|
||||
|
||||
Args:
|
||||
ground_truth: Ground truth table data. The tables text should be in the deckerd format.
|
||||
prediction: Predicted table data.
|
||||
cutoff: The cutoff value for the element level alignment. Default is 0.8.
|
||||
|
||||
Examples:
|
||||
ground_truth: [
|
||||
{
|
||||
"type": "Table",
|
||||
"text": [
|
||||
{
|
||||
"id": "f4c35dae-105b-46f5-a77a-7fbc199d6aca",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"content": "Cell text"
|
||||
},
|
||||
...
|
||||
}
|
||||
]
|
||||
prediction: [
|
||||
{
|
||||
"element_id": <id_string>,
|
||||
...
|
||||
"metadata": {
|
||||
...
|
||||
"text_as_html": "<table><thead><tr><th rowspan=\"2\">June....
|
||||
</tr></td></table>",
|
||||
"table_as_cells":
|
||||
[
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 2,
|
||||
"content": "June"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
"""
|
||||
self.prediction = prediction
|
||||
self.ground_truth = ground_truth
|
||||
self.cutoff = cutoff
|
||||
self.source_type = source_type
|
||||
|
||||
@classmethod
|
||||
def from_json_files(
|
||||
cls,
|
||||
prediction_file: Path,
|
||||
ground_truth_file: Path,
|
||||
cutoff: Optional[float] = None,
|
||||
source_type: str = "html",
|
||||
) -> "TableEvalProcessor":
|
||||
"""Factory classmethod to initialize the object with path to json files instead of dicts
|
||||
|
||||
Args:
|
||||
prediction_file: Path to the json file containing the predicted table data.
|
||||
ground_truth_file: Path to the json file containing the ground truth table data.
|
||||
source_type: 'cells' or 'html'. 'cells' refers to reading 'table_as_cells' field while
|
||||
'html' is extracted from 'text_as_html'
|
||||
cutoff: The cutoff value for the element level alignment.
|
||||
If not set, class default value is used (=0.8).
|
||||
|
||||
Returns:
|
||||
TableEvalProcessor: An instance of the class initialized with the provided data.
|
||||
"""
|
||||
with open(prediction_file) as f:
|
||||
prediction = json.load(f)
|
||||
with open(ground_truth_file) as f:
|
||||
ground_truth = json.load(f)
|
||||
if cutoff is not None:
|
||||
return cls(
|
||||
prediction=prediction,
|
||||
ground_truth=ground_truth,
|
||||
cutoff=cutoff,
|
||||
source_type=source_type,
|
||||
)
|
||||
else:
|
||||
return cls(prediction=prediction, ground_truth=ground_truth, source_type=source_type)
|
||||
|
||||
def process_file(self) -> TableEvaluation:
|
||||
"""Processes the files and computes table-level and element-level accuracy.
|
||||
|
||||
Returns:
|
||||
TableEvaluation: A dataclass object containing the computed metrics.
|
||||
"""
|
||||
ground_truth_table_data = extract_and_convert_tables_from_ground_truth(
|
||||
self.ground_truth,
|
||||
)
|
||||
|
||||
predicted_table_data = extract_and_convert_tables_from_prediction(
|
||||
file_elements=self.prediction, source_type=self.source_type
|
||||
)
|
||||
is_table_in_gt = bool(ground_truth_table_data)
|
||||
is_table_predicted = bool(predicted_table_data)
|
||||
if not is_table_in_gt:
|
||||
# There is no table data in ground truth, you either got perfect score or 0
|
||||
score = 0 if is_table_predicted else np.nan
|
||||
table_acc = 1 if not is_table_predicted else 0
|
||||
return TableEvaluation(
|
||||
total_tables=0,
|
||||
total_predicted_tables=len(predicted_table_data),
|
||||
table_level_acc=table_acc,
|
||||
table_detection_recall=score,
|
||||
table_detection_precision=score,
|
||||
table_detection_f1=score,
|
||||
element_col_level_index_acc=score,
|
||||
element_row_level_index_acc=score,
|
||||
element_col_level_content_acc=score,
|
||||
element_row_level_content_acc=score,
|
||||
)
|
||||
if is_table_in_gt and not is_table_predicted:
|
||||
return TableEvaluation(
|
||||
total_tables=len(ground_truth_table_data),
|
||||
total_predicted_tables=0,
|
||||
table_level_acc=0,
|
||||
table_detection_recall=0,
|
||||
table_detection_precision=0,
|
||||
table_detection_f1=0,
|
||||
element_col_level_index_acc=0,
|
||||
element_row_level_index_acc=0,
|
||||
element_col_level_content_acc=0,
|
||||
element_row_level_content_acc=0,
|
||||
)
|
||||
else:
|
||||
# We have both ground truth tables and predicted tables
|
||||
matched_indices = TableAlignment.get_table_level_alignment(
|
||||
predicted_table_data,
|
||||
ground_truth_table_data,
|
||||
)
|
||||
predicted_table_acc = np.mean(
|
||||
table_level_acc(predicted_table_data, ground_truth_table_data, matched_indices)
|
||||
)
|
||||
|
||||
metrics = TableAlignment.get_element_level_alignment(
|
||||
predicted_table_data,
|
||||
ground_truth_table_data,
|
||||
matched_indices,
|
||||
cutoff=self.cutoff,
|
||||
)
|
||||
|
||||
(
|
||||
table_detection_recall,
|
||||
table_detection_precision,
|
||||
table_detection_f1,
|
||||
) = calculate_table_detection_metrics(
|
||||
matched_indices=matched_indices,
|
||||
ground_truth_tables_number=len(ground_truth_table_data),
|
||||
)
|
||||
|
||||
evaluation = TableEvaluation(
|
||||
total_tables=len(ground_truth_table_data),
|
||||
total_predicted_tables=len(predicted_table_data),
|
||||
table_level_acc=predicted_table_acc,
|
||||
table_detection_recall=table_detection_recall,
|
||||
table_detection_precision=table_detection_precision,
|
||||
table_detection_f1=table_detection_f1,
|
||||
element_col_level_index_acc=metrics.get("col_index_acc", 0),
|
||||
element_row_level_index_acc=metrics.get("row_index_acc", 0),
|
||||
element_col_level_content_acc=metrics.get("col_content_acc", 0),
|
||||
element_row_level_content_acc=metrics.get("row_content_acc", 0),
|
||||
)
|
||||
return evaluation
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--prediction_file", help="Path to the model prediction JSON file", type=click.Path(exists=True)
|
||||
)
|
||||
@click.option(
|
||||
"--ground_truth_file", help="Path to the ground truth JSON file", type=click.Path(exists=True)
|
||||
)
|
||||
@click.option(
|
||||
"--cutoff",
|
||||
type=float,
|
||||
show_default=True,
|
||||
default=0.8,
|
||||
help="The cutoff value for the element level alignment. \
|
||||
If not set, a default value is used",
|
||||
)
|
||||
def run(prediction_file: str, ground_truth_file: str, cutoff: Optional[float]):
|
||||
"""Runs the table evaluation process and prints the computed metrics."""
|
||||
processor = TableEvalProcessor.from_json_files(
|
||||
Path(prediction_file),
|
||||
Path(ground_truth_file),
|
||||
cutoff=cutoff,
|
||||
)
|
||||
report = processor.process_file()
|
||||
print(report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from unstructured_inference.models.tables import cells_to_html
|
||||
|
||||
EMPTY_CELL = {
|
||||
"row_index": "",
|
||||
"col_index": "",
|
||||
"content": "",
|
||||
}
|
||||
|
||||
|
||||
def _move_cells_for_spanned_cells(cells: List[Dict[str, Any]]):
|
||||
"""Move cells to the right if spanned cells have an influence on the rendering.
|
||||
|
||||
Args:
|
||||
cells: List of cells in the table in Deckerd format.
|
||||
|
||||
Returns:
|
||||
List of cells in the table in Deckerd format with cells moved to the right if spanned.
|
||||
"""
|
||||
sorted_cells = sorted(cells, key=lambda x: (x["y"], x["x"]))
|
||||
cells_occupied_by_spanned = set()
|
||||
for cell in sorted_cells:
|
||||
if cell["w"] > 1 or cell["h"] > 1:
|
||||
for i in range(cell["y"], cell["y"] + cell["h"]):
|
||||
for j in range(cell["x"], cell["x"] + cell["w"]):
|
||||
if (i, j) != (cell["y"], cell["x"]):
|
||||
cells_occupied_by_spanned.add((i, j))
|
||||
while (cell["y"], cell["x"]) in cells_occupied_by_spanned:
|
||||
cell_y, cell_x = cell["y"], cell["x"]
|
||||
cells_to_the_right = [c for c in sorted_cells if c["y"] == cell_y and c["x"] >= cell_x]
|
||||
for cell_to_move in cells_to_the_right:
|
||||
cell_to_move["x"] += 1
|
||||
cells_occupied_by_spanned.remove((cell_y, cell_x))
|
||||
return sorted_cells
|
||||
|
||||
|
||||
def html_table_to_deckerd(content: str) -> List[Dict[str, Any]]:
|
||||
"""Convert html format to Deckerd table structure.
|
||||
|
||||
Args:
|
||||
content: The html content with a table to extract.
|
||||
|
||||
Returns:
|
||||
A list of dictionaries where each dictionary represents a cell in the table.
|
||||
"""
|
||||
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
table = soup.find("table")
|
||||
rows = table.find_all(["tr"])
|
||||
table_data = []
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
cells = row.find_all(["th", "td"])
|
||||
for j, cell_data in enumerate(cells):
|
||||
cell = {
|
||||
"y": i,
|
||||
"x": j,
|
||||
"w": int(cell_data.attrs.get("colspan", 1)),
|
||||
"h": int(cell_data.attrs.get("rowspan", 1)),
|
||||
"content": cell_data.text,
|
||||
}
|
||||
table_data.append(cell)
|
||||
return _move_cells_for_spanned_cells(table_data)
|
||||
|
||||
|
||||
def deckerd_table_to_html(cells: List[Dict[str, Any]]) -> str:
|
||||
"""Convert Deckerd table structure to html format.
|
||||
|
||||
Args:
|
||||
cells: List of dictionaries where each dictionary represents a cell in the table.
|
||||
|
||||
Returns:
|
||||
A string with the html content of the table.
|
||||
"""
|
||||
transformer_cells = []
|
||||
# determine which cells are in header. Consider row 0 as header
|
||||
# but spans may make it larger
|
||||
first_row_cells = [cell for cell in cells if cell["y"] == 0]
|
||||
header_length = max(cell["w"] for cell in first_row_cells)
|
||||
header_rows = set(range(header_length))
|
||||
for cell in cells:
|
||||
cell_data = {
|
||||
"row_nums": list(range(cell["y"], cell["y"] + cell["h"])),
|
||||
"column_nums": list(range(cell["x"], cell["x"] + cell["w"])),
|
||||
"w": cell["w"],
|
||||
"h": cell["h"],
|
||||
"cell text": cell["content"],
|
||||
"column header": cell["y"] in header_rows,
|
||||
}
|
||||
transformer_cells.append(cell_data)
|
||||
# reuse the existing function to convert to HTML
|
||||
table = cells_to_html(transformer_cells)
|
||||
return table
|
||||
|
||||
|
||||
def _convert_table_from_html(content: str) -> List[Dict[str, Any]]:
|
||||
"""Convert html format to table structure. As a middle step it converts
|
||||
html to the Deckerd format as it's more convenient to work with.
|
||||
|
||||
Args:
|
||||
content: The html content with a table to extract.
|
||||
|
||||
Returns:
|
||||
A list of dictionaries where each dictionary represents a cell in the table.
|
||||
"""
|
||||
deckerd_cells = html_table_to_deckerd(content)
|
||||
return _convert_table_from_deckerd(deckerd_cells)
|
||||
|
||||
|
||||
def _convert_table_from_deckerd(content: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Convert deckerd format to table structure.
|
||||
|
||||
Args:
|
||||
content: The deckerd formatted content with a table to extract.
|
||||
|
||||
Returns:
|
||||
A list of dictionaries where each dictionary represents a cell in the table.
|
||||
"""
|
||||
table_data = []
|
||||
for table in content:
|
||||
try:
|
||||
cell_data = {
|
||||
"row_index": table["y"],
|
||||
"col_index": table["x"],
|
||||
"content": table["content"],
|
||||
}
|
||||
except KeyError:
|
||||
cell_data = EMPTY_CELL
|
||||
except TypeError:
|
||||
cell_data = EMPTY_CELL
|
||||
table_data.append(cell_data)
|
||||
return table_data
|
||||
|
||||
|
||||
def _sort_table_cells(table_data: List[List[Dict[str, Any]]]) -> List[List[Dict[str, Any]]]:
|
||||
return sorted(table_data, key=lambda cell: (cell["row_index"], cell["col_index"]))
|
||||
|
||||
|
||||
def extract_and_convert_tables_from_ground_truth(
|
||||
file_elements: List[Dict[str, Any]],
|
||||
) -> List[List[Dict[str, Any]]]:
|
||||
"""Extracts and converts tables data to a structured format based on the specified table type.
|
||||
|
||||
Args:
|
||||
file_elements: List of elements from the ground truth file.
|
||||
|
||||
Returns:
|
||||
A list of tables with each table represented as a list of cell data dictionaries.
|
||||
|
||||
"""
|
||||
ground_truth_table_data = []
|
||||
for element in file_elements:
|
||||
if "type" in element and element["type"] == "Table" and "text" in element:
|
||||
try:
|
||||
converted_data = _convert_table_from_deckerd(
|
||||
element["text"],
|
||||
)
|
||||
ground_truth_table_data.append(_sort_table_cells(converted_data))
|
||||
except Exception as e:
|
||||
print(f"Error converting ground truth data: {e}")
|
||||
ground_truth_table_data.append({})
|
||||
|
||||
return ground_truth_table_data
|
||||
|
||||
|
||||
def extract_and_convert_tables_from_prediction(
|
||||
file_elements: List[Dict[str, Any]], source_type: str = "html"
|
||||
) -> List[List[Dict[str, Any]]]:
|
||||
"""Extracts and converts table data to a structured format
|
||||
|
||||
Args:
|
||||
file_elements: List of elements from the file.
|
||||
source_type: 'cells' or 'html'. 'cells' refers to reading 'table_as_cells' field while
|
||||
'html' is extracted from 'text_as_html'
|
||||
|
||||
Returns:
|
||||
A list of tables with each table represented as a list of cell data dictionaries.
|
||||
|
||||
"""
|
||||
source_type_to_extraction_strategies = {
|
||||
"html": extract_cells_from_text_as_html,
|
||||
"cells": extract_cells_from_table_as_cells,
|
||||
}
|
||||
if source_type not in source_type_to_extraction_strategies:
|
||||
raise ValueError(
|
||||
f'source_type {source_type} is not valid. Allowed source_types are "html" and "cells"'
|
||||
)
|
||||
|
||||
extract_cells_fn = source_type_to_extraction_strategies[source_type]
|
||||
fallback_extract_cells_fn = (
|
||||
extract_cells_from_table_as_cells
|
||||
if source_type == "cells"
|
||||
else extract_cells_from_text_as_html
|
||||
)
|
||||
|
||||
predicted_table_data = []
|
||||
for element in file_elements:
|
||||
if element.get("type") == "Table":
|
||||
extracted_cells = extract_cells_fn(element)
|
||||
if not extracted_cells:
|
||||
extracted_cells = fallback_extract_cells_fn(element)
|
||||
if extracted_cells:
|
||||
sorted_cells = _sort_table_cells(extracted_cells)
|
||||
predicted_table_data.append(sorted_cells)
|
||||
|
||||
return predicted_table_data
|
||||
|
||||
|
||||
def extract_cells_from_text_as_html(element: Dict[str, Any]) -> List[Dict[str, Any]] | None:
|
||||
"""Extracts and parse cells from "text_as_html" field in Element structure
|
||||
|
||||
Args:
|
||||
element: Example element:
|
||||
{
|
||||
"type": "Table",
|
||||
"metadata": {
|
||||
"text_as_html": "<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month A.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</tbody>
|
||||
<tr>
|
||||
<td>22</td><
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>"
|
||||
}
|
||||
}
|
||||
|
||||
Returns:
|
||||
List of extracted cells in a format:
|
||||
[
|
||||
{
|
||||
"row_index": 0,
|
||||
"col_index": 0,
|
||||
"content": "Month A.",
|
||||
},
|
||||
...,
|
||||
]
|
||||
"""
|
||||
val = element["metadata"].get("text_as_html")
|
||||
if not val or "<table>" not in val:
|
||||
return None
|
||||
|
||||
predicted_cells = None
|
||||
try:
|
||||
predicted_cells = _convert_table_from_html(val)
|
||||
except Exception as e:
|
||||
print(f"Error converting Unstructured table data: {e}")
|
||||
|
||||
return predicted_cells
|
||||
|
||||
|
||||
def extract_cells_from_table_as_cells(element: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Extracts and parse cells from "table_as_cells" field in Element structure
|
||||
|
||||
Args:
|
||||
element: Example element:
|
||||
{
|
||||
"type": "Table",
|
||||
"metadata": {
|
||||
"table_as_cells": [{"x": 0, "y": 0, "w": 1, "h": 1, "content": "Month A."},
|
||||
{"x": 0, "y": 1, "w": 1, "h": 1, "content": "22"}]
|
||||
}
|
||||
}
|
||||
|
||||
Returns:
|
||||
List of extracted cells in a format:
|
||||
[
|
||||
{
|
||||
"row_index": 0,
|
||||
"col_index": 0,
|
||||
"content": "Month A.",
|
||||
},
|
||||
...,
|
||||
]
|
||||
"""
|
||||
predicted_cells = element["metadata"].get("table_as_cells")
|
||||
converted_cells = None
|
||||
if predicted_cells:
|
||||
converted_cells = _convert_table_from_deckerd(predicted_cells)
|
||||
return converted_cells
|
||||
@@ -0,0 +1,49 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Union
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimpleTableCell:
|
||||
x: int
|
||||
y: int
|
||||
w: int
|
||||
h: int
|
||||
content: str = ""
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"x": self.x,
|
||||
"y": self.y,
|
||||
"w": self.w,
|
||||
"h": self.h,
|
||||
"content": self.content,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_table_transformer_cell(cls, tatr_table_cell: dict[str, Union[list[int], str]]):
|
||||
"""
|
||||
Args:
|
||||
tatr_table_cell (dict):
|
||||
Cell in a format returned by Table Transformer model, for example:
|
||||
{
|
||||
"row_nums": [1,2,3],
|
||||
"column_nums": [2],
|
||||
"cell text": "Text inside cell"
|
||||
}
|
||||
"""
|
||||
|
||||
row_nums = tatr_table_cell.get("row_nums", [])
|
||||
column_nums = tatr_table_cell.get("column_nums", [])
|
||||
|
||||
if not row_nums:
|
||||
raise ValueError(f'Cell {tatr_table_cell} has missing values under "row_nums" key')
|
||||
if not column_nums:
|
||||
raise ValueError(f'Cell {tatr_table_cell} has missing values under "column_nums" key')
|
||||
|
||||
return cls(
|
||||
x=min(column_nums),
|
||||
y=min(row_nums),
|
||||
w=len(column_nums),
|
||||
h=len(row_nums),
|
||||
content=tatr_table_cell.get("cell text", ""),
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from PIL import Image
|
||||
|
||||
from unstructured.partition.pdf import convert_pdf_to_images
|
||||
from unstructured.partition.pdf_image.ocr import get_table_tokens
|
||||
from unstructured.partition.utils.ocr_models.ocr_interface import OCRAgent
|
||||
from unstructured.utils import requires_dependencies
|
||||
|
||||
|
||||
@requires_dependencies("unstructured_inference")
|
||||
def image_or_pdf_to_dataframe(filename: str) -> pd.DataFrame:
|
||||
"""helper to JUST run table transformer on the input image/pdf file. It assumes the input is
|
||||
JUST a table. This is intended to facilitate metric tracking on table structure detection ALONE
|
||||
without mixing metric of element detection model"""
|
||||
from unstructured_inference.models.tables import load_agent, tables_agent
|
||||
|
||||
load_agent()
|
||||
|
||||
if filename.endswith(".pdf"):
|
||||
image = list(convert_pdf_to_images(filename))[0].convert("RGB")
|
||||
else:
|
||||
image = Image.open(filename).convert("RGB")
|
||||
|
||||
ocr_agent = OCRAgent.get_agent(language="eng")
|
||||
|
||||
return tables_agent.run_prediction(
|
||||
image, ocr_tokens=get_table_tokens(image, ocr_agent), result_format="dataframe"
|
||||
)
|
||||
|
||||
|
||||
@requires_dependencies("unstructured_inference")
|
||||
def eval_table_transformer_for_file(
|
||||
filename: str,
|
||||
true_table_filename: str,
|
||||
eval_func: str = "token_ratio",
|
||||
) -> float:
|
||||
"""evaluate the predicted table structure vs. actual table structure by column and row as a
|
||||
number between 0 and 1"""
|
||||
from unstructured_inference.models.eval import compare_contents_as_df
|
||||
|
||||
pred_table = image_or_pdf_to_dataframe(filename).fillna("").replace(np.nan, "")
|
||||
actual_table = pd.read_csv(true_table_filename).astype(str).fillna("").replace(np.nan, "")
|
||||
|
||||
results = np.array(
|
||||
list(compare_contents_as_df(actual_table, pred_table, eval_func=eval_func).values()),
|
||||
)
|
||||
return results.mean() / 100.0
|
||||
@@ -0,0 +1,251 @@
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from rapidfuzz.distance import Levenshtein
|
||||
|
||||
from unstructured.cleaners.core import clean_bullets, remove_sentence_punctuation
|
||||
|
||||
|
||||
def calculate_accuracy(
|
||||
output: Optional[str],
|
||||
source: Optional[str],
|
||||
weights: Tuple[int, int, int] = (2, 1, 1),
|
||||
) -> float:
|
||||
"""
|
||||
Calculates accuracy by calling calculate_edit_distance function using `return_as=score`.
|
||||
The function will return complement of the edit distance instead.
|
||||
"""
|
||||
return calculate_edit_distance(output, source, weights, return_as="score")
|
||||
|
||||
|
||||
def calculate_edit_distance(
|
||||
output: Optional[str],
|
||||
source: Optional[str],
|
||||
weights: Tuple[int, int, int] = (2, 1, 1),
|
||||
return_as: str = "distance",
|
||||
standardize_whitespaces: bool = True,
|
||||
) -> float:
|
||||
"""
|
||||
Calculates edit distance using Levenshtein distance between two strings.
|
||||
|
||||
Args:
|
||||
output (str): The target string to be compared.
|
||||
source (str): The reference string against which 'output' is compared.
|
||||
weights (Tuple[int, int, int], optional): A tuple containing weights
|
||||
for insertion, deletion, and substitution operations in the edit
|
||||
distance calculation. Default is (2, 1, 1).
|
||||
return_as (str, optional): The type of result to return, one of
|
||||
["score", "distance"].
|
||||
Default is "distance".
|
||||
|
||||
Returns:
|
||||
float: The calculated edit distance or similarity score between
|
||||
the 'output' and 'source' strings.
|
||||
|
||||
Raises:
|
||||
ValueError: If 'return_as' is not one of the valid return types
|
||||
["score", "distance"].
|
||||
|
||||
Note:
|
||||
This function calculates the edit distance (or similarity score) between
|
||||
two strings using the Levenshtein distance algorithm. The 'weights' parameter
|
||||
allows customizing the cost of insertion, deletion, and substitution
|
||||
operations. The 'return_as' parameter determines the type of result to return:
|
||||
- "score": Returns the similarity score, where 1.0 indicates a perfect match.
|
||||
- "distance": Returns the raw edit distance value.
|
||||
|
||||
"""
|
||||
return_types = ["score", "distance"]
|
||||
if return_as not in return_types:
|
||||
raise ValueError("Invalid return value type. Expected one of: %s" % return_types)
|
||||
output = standardize_quotes(prepare_str(output, standardize_whitespaces))
|
||||
source = standardize_quotes(prepare_str(source, standardize_whitespaces))
|
||||
distance = Levenshtein.distance(output, source, weights=weights) # type: ignore
|
||||
# lower bounded the char length for source string at 1.0 because to avoid division by zero
|
||||
# in the case where source string is empty, the distance should be at 100%
|
||||
source_char_len = max(len(source), 1.0) # type: ignore
|
||||
bounded_percentage_distance = min(max(distance / source_char_len, 0.0), 1.0)
|
||||
if return_as == "score":
|
||||
return 1 - bounded_percentage_distance
|
||||
elif return_as == "distance":
|
||||
return distance
|
||||
return 0.0
|
||||
|
||||
|
||||
def bag_of_words(text: str) -> Dict[str, int]:
|
||||
"""
|
||||
Outputs the bag of words (BOW) found in the input text and their frequencies.
|
||||
|
||||
Takes "clean, concatenated text" (CCT) from a document as input.
|
||||
|
||||
Removes sentence punctuation, but not punctuation within a word (ex. apostrophes).
|
||||
"""
|
||||
bow: Dict[str, int] = {}
|
||||
incorrect_word: str = ""
|
||||
words = clean_bullets(remove_sentence_punctuation(text.lower(), ["-", "'"])).split()
|
||||
|
||||
i = 0
|
||||
while i < len(words):
|
||||
if len(words[i]) > 1:
|
||||
if words[i] in bow:
|
||||
bow[words[i]] += 1
|
||||
else:
|
||||
bow[words[i]] = 1
|
||||
i += 1
|
||||
else:
|
||||
j = i
|
||||
incorrect_word = ""
|
||||
|
||||
while j < len(words) and len(words[j]) == 1:
|
||||
incorrect_word += words[j]
|
||||
j += 1
|
||||
|
||||
if len(incorrect_word) == 1 and words[i].isalnum():
|
||||
if incorrect_word in bow:
|
||||
bow[incorrect_word] += 1
|
||||
else:
|
||||
bow[incorrect_word] = 1
|
||||
i = j
|
||||
return bow
|
||||
|
||||
|
||||
def calculate_percent_missing_text(
|
||||
output: Optional[str],
|
||||
source: Optional[str],
|
||||
) -> float:
|
||||
"""
|
||||
Creates the bag of words (BOW) found in each input text and their frequencies, then compares the
|
||||
output BOW against the source BOW to calculate the % of text from the source text missing from
|
||||
the output text.
|
||||
|
||||
Takes "clean, concatenated text" (CCT) from a document output and the ground truth source text
|
||||
as inputs.
|
||||
|
||||
If the output text contains all words from the source text and then some extra, result will be
|
||||
0% missing text - this calculation does not penalize duplication.
|
||||
|
||||
A spaced-out word (ex. h e l l o) is considered missing; individual characters of a word
|
||||
will not be counted as separate words.
|
||||
|
||||
Returns the percentage of missing text represented as a decimal between 0 and 1.
|
||||
"""
|
||||
output = prepare_str(output)
|
||||
source = prepare_str(source)
|
||||
output_bow = bag_of_words(output)
|
||||
source_bow = bag_of_words(source)
|
||||
|
||||
# get total words in source bow while counting missing words
|
||||
total_source_word_count = 0
|
||||
total_missing_word_count = 0
|
||||
|
||||
for source_word, source_count in source_bow.items():
|
||||
total_source_word_count += source_count
|
||||
if source_word not in output_bow:
|
||||
# entire count is missing
|
||||
total_missing_word_count += source_count
|
||||
else:
|
||||
output_count = output_bow[source_word]
|
||||
total_missing_word_count += max(source_count - output_count, 0)
|
||||
|
||||
# calculate percent missing text
|
||||
if total_source_word_count == 0:
|
||||
return 0 # nothing missing because nothing in source document
|
||||
|
||||
fraction_missing = round(total_missing_word_count / total_source_word_count, 3)
|
||||
return min(fraction_missing, 1) # limit to 100%
|
||||
|
||||
|
||||
def prepare_str(string: Optional[str], standardize_whitespaces: bool = False) -> str:
|
||||
if not string:
|
||||
return ""
|
||||
if standardize_whitespaces:
|
||||
return " ".join(string.split())
|
||||
return str(string) # type: ignore
|
||||
|
||||
|
||||
def standardize_quotes(text: str) -> str:
|
||||
"""
|
||||
Converts all unicode quotes to standard ASCII quotes with comprehensive coverage.
|
||||
|
||||
Args:
|
||||
text (str): The input text to be standardized.
|
||||
|
||||
Returns:
|
||||
str: The text with standardized quotes.
|
||||
"""
|
||||
# Double Quotes Dictionary
|
||||
double_quotes = {
|
||||
'"': "U+0022", # noqa 601 # Standard typewriter/programmer's quote
|
||||
'"': "U+201C", # noqa 601 # Left double quotation mark
|
||||
'"': "U+201D", # noqa 601 # Right double quotation mark
|
||||
"„": "U+201E", # Double low-9 quotation mark
|
||||
"‟": "U+201F", # Double high-reversed-9 quotation mark
|
||||
"«": "U+00AB", # Left-pointing double angle quotation mark
|
||||
"»": "U+00BB", # Right-pointing double angle quotation mark
|
||||
"❝": "U+275D", # Heavy double turned comma quotation mark ornament
|
||||
"❞": "U+275E", # Heavy double comma quotation mark ornament
|
||||
"⹂": "U+2E42", # Double low-reversed-9 quotation mark
|
||||
"🙶": "U+1F676", # SANS-SERIF HEAVY DOUBLE TURNED COMMA QUOTATION MARK ORNAMENT
|
||||
"🙷": "U+1F677", # SANS-SERIF HEAVY DOUBLE COMMA QUOTATION MARK ORNAMENT
|
||||
"🙸": "U+1F678", # SANS-SERIF HEAVY LOW DOUBLE COMMA QUOTATION MARK ORNAMENT
|
||||
"⠦": "U+2826", # Braille double closing quotation mark
|
||||
"⠴": "U+2834", # Braille double opening quotation mark
|
||||
"〝": "U+301D", # REVERSED DOUBLE PRIME QUOTATION MARK
|
||||
"〞": "U+301E", # DOUBLE PRIME QUOTATION MARK
|
||||
"〟": "U+301F", # LOW DOUBLE PRIME QUOTATION MARK
|
||||
""": "U+FF02", # FULLWIDTH QUOTATION MARK
|
||||
",,": "U+275E", # LOW HEAVY DOUBLE COMMA ORNAMENT
|
||||
}
|
||||
|
||||
# Single Quotes Dictionary
|
||||
single_quotes = {
|
||||
"'": "U+0027", # noqa 601 # Standard typewriter/programmer's quote
|
||||
"'": "U+2018", # noqa 601 # Left single quotation mark
|
||||
"'": "U+2019", # noqa 601 # Right single quotation mark # noqa: W605
|
||||
"‚": "U+201A", # Single low-9 quotation mark
|
||||
"‛": "U+201B", # Single high-reversed-9 quotation mark
|
||||
"‹": "U+2039", # Single left-pointing angle quotation mark
|
||||
"›": "U+203A", # Single right-pointing angle quotation mark
|
||||
"❛": "U+275B", # Heavy single turned comma quotation mark ornament
|
||||
"❜": "U+275C", # Heavy single comma quotation mark ornament
|
||||
"「": "U+300C", # Left corner bracket
|
||||
"」": "U+300D", # Right corner bracket
|
||||
"『": "U+300E", # Left white corner bracket
|
||||
"』": "U+300F", # Right white corner bracket
|
||||
"﹁": "U+FE41", # PRESENTATION FORM FOR VERTICAL LEFT CORNER BRACKET
|
||||
"﹂": "U+FE42", # PRESENTATION FORM FOR VERTICAL RIGHT CORNER BRACKET
|
||||
"﹃": "U+FE43", # PRESENTATION FORM FOR VERTICAL LEFT WHITE CORNER BRACKET
|
||||
"﹄": "U+FE44", # PRESENTATION FORM FOR VERTICAL RIGHT WHITE CORNER BRACKET
|
||||
"'": "U+FF07", # FULLWIDTH APOSTROPHE
|
||||
"「": "U+FF62", # HALFWIDTH LEFT CORNER BRACKET
|
||||
"」": "U+FF63", # HALFWIDTH RIGHT CORNER BRACKET
|
||||
}
|
||||
|
||||
double_quote_standard = '"'
|
||||
single_quote_standard = "'"
|
||||
|
||||
# Apply double quote replacements
|
||||
for unicode_val in double_quotes.values():
|
||||
unicode_char = unicode_to_char(unicode_val)
|
||||
if unicode_char in text:
|
||||
text = text.replace(unicode_char, double_quote_standard)
|
||||
|
||||
# Apply single quote replacements
|
||||
for unicode_val in single_quotes.values():
|
||||
unicode_char = unicode_to_char(unicode_val)
|
||||
if unicode_char in text:
|
||||
text = text.replace(unicode_char, single_quote_standard)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def unicode_to_char(unicode_val: str) -> str:
|
||||
"""
|
||||
Converts a Unicode value to a character.
|
||||
|
||||
Args:
|
||||
unicode_val (str): The Unicode value to convert.
|
||||
|
||||
Returns:
|
||||
str: The character corresponding to the Unicode value.
|
||||
"""
|
||||
return chr(int(unicode_val.replace("U+", ""), 16))
|
||||
@@ -0,0 +1,246 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
|
||||
import click
|
||||
import pandas as pd
|
||||
|
||||
from unstructured.staging.base import elements_from_json, elements_to_text
|
||||
|
||||
logger = logging.getLogger("unstructured.eval")
|
||||
|
||||
|
||||
def _prepare_output_cct(docpath: str, output_type: str) -> str:
|
||||
"""
|
||||
Convert given input document (path) into cct-ready. The function only support conversion
|
||||
from `json` or `txt` file.
|
||||
"""
|
||||
try:
|
||||
if output_type == "json":
|
||||
output_cct = elements_to_text(elements_from_json(docpath))
|
||||
elif output_type == "txt":
|
||||
output_cct = _read_text_file(docpath)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"File type not supported. Expects one of `json` or `txt`, \
|
||||
but received {output_type} instead."
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error(f"Could not read the file {docpath}")
|
||||
raise e
|
||||
return output_cct
|
||||
|
||||
|
||||
def _listdir_recursive(dir: str) -> List[str]:
|
||||
"""
|
||||
Recursively lists all files in the given directory and its subdirectories.
|
||||
Returns a list of all files found, with each file's path relative to the
|
||||
initial directory.
|
||||
"""
|
||||
listdir = []
|
||||
for dirpath, _, filenames in os.walk(dir):
|
||||
for filename in filenames:
|
||||
# Remove the starting directory from the path to show the relative path
|
||||
relative_path = os.path.relpath(dirpath, dir)
|
||||
if relative_path == ".":
|
||||
listdir.append(filename)
|
||||
else:
|
||||
listdir.append(os.path.join(relative_path, filename))
|
||||
return listdir
|
||||
|
||||
|
||||
def _rename_aggregated_columns(df):
|
||||
"""
|
||||
Renames aggregated columns in a DataFrame based on a predefined mapping.
|
||||
|
||||
Parameters:
|
||||
df (pandas.DataFrame): The DataFrame with aggregated columns to rename.
|
||||
|
||||
Returns:
|
||||
pandas.DataFrame: A new DataFrame with renamed aggregated columns.
|
||||
"""
|
||||
rename_map = {"_mean": "mean", "_stdev": "stdev", "_pstdev": "pstdev", "_count": "count"}
|
||||
return df.rename(columns=rename_map)
|
||||
|
||||
|
||||
def _format_grouping_output(*df):
|
||||
"""
|
||||
Concatenates multiple pandas DataFrame objects along the columns (side-by-side)
|
||||
and resets the index.
|
||||
"""
|
||||
return pd.concat(df, axis=1).reset_index()
|
||||
|
||||
|
||||
def _display(df):
|
||||
"""
|
||||
Displays the evaluation metrics in a formatted text table.
|
||||
"""
|
||||
if len(df) == 0:
|
||||
return
|
||||
headers = df.columns.tolist()
|
||||
col_widths = [
|
||||
max(len(header), max(len(str(item)) for item in df[header])) for header in headers
|
||||
]
|
||||
click.echo(" ".join(header.ljust(col_widths[i]) for i, header in enumerate(headers)))
|
||||
click.echo("-" * sum(col_widths) + "-" * (len(headers) - 1))
|
||||
for _, row in df.iterrows():
|
||||
formatted_row = []
|
||||
for item in row:
|
||||
if isinstance(item, float):
|
||||
formatted_row.append(f"{item:.3f}")
|
||||
else:
|
||||
formatted_row.append(str(item))
|
||||
click.echo(
|
||||
" ".join(formatted_row[i].ljust(col_widths[i]) for i in range(len(formatted_row))),
|
||||
)
|
||||
|
||||
|
||||
def _write_to_file(
|
||||
directory: str, filename: str, df: pd.DataFrame, mode: str = "w", overwrite: bool = True
|
||||
):
|
||||
"""
|
||||
Save the metrics report to tsv file. The function allows an option 1) to choose `mode`
|
||||
as `w` (write) or `a` (append) and 2) to `overwrite` the file if filename existed or not.
|
||||
"""
|
||||
if mode not in ["w", "a"]:
|
||||
raise ValueError("Mode not supported. Mode must be one of [w, a].")
|
||||
if directory:
|
||||
Path(directory).mkdir(exist_ok=True)
|
||||
if "count" in df.columns:
|
||||
df["count"] = df["count"].astype(int)
|
||||
if "filename" in df.columns and "connector" in df.columns:
|
||||
df.sort_values(by=["connector", "filename"], inplace=True)
|
||||
if not overwrite:
|
||||
filename = _get_non_duplicated_filename(directory, filename)
|
||||
df.to_csv(
|
||||
os.path.join(directory, filename), sep="\t", mode=mode, index=False, header=(mode == "w")
|
||||
)
|
||||
|
||||
|
||||
def _sorting_key(filename):
|
||||
"""
|
||||
A function that defines the sorting method for duplicated file names. For example,
|
||||
with filename.ext filename (1).ext filename (2).ext filename (10).ext - this function
|
||||
extracts the integer in the bracket and sort those numbers ascendingly.
|
||||
"""
|
||||
# Regular expression to find the number in the filename
|
||||
numbers = re.findall(r"(\d+)", filename)
|
||||
if numbers:
|
||||
# If there's a number, return it as an integer for sorting
|
||||
return int(numbers[-1])
|
||||
else:
|
||||
# If no number, return 0 so these files come first
|
||||
return 0
|
||||
|
||||
|
||||
def _uniquity_file(file_list, target_filename) -> str:
|
||||
"""
|
||||
Checks the duplicity of the file name from the list and run the numerical check
|
||||
of the minimum number needed as extension to not overwrite the exising file.
|
||||
Returns a string of file name in the format of `filename (<min number>).ext`.
|
||||
"""
|
||||
original_filename, extension = target_filename.rsplit(".", 1)
|
||||
pattern = rf"^{re.escape(original_filename)}(?: \((\d+)\))?\.{re.escape(extension)}$"
|
||||
duplicated_files = sorted([f for f in file_list if re.match(pattern, f)], key=_sorting_key)
|
||||
|
||||
numbers = []
|
||||
for file in duplicated_files:
|
||||
match = re.search(r"\((\d+)\)", file)
|
||||
if match:
|
||||
numbers.append(int(match.group(1)))
|
||||
|
||||
numbers.sort()
|
||||
|
||||
counter = 1
|
||||
for number in numbers:
|
||||
if number == counter:
|
||||
counter += 1
|
||||
else:
|
||||
break
|
||||
|
||||
return original_filename + " (" + str(counter) + ")." + extension
|
||||
|
||||
|
||||
def _get_non_duplicated_filename(dir, filename) -> str:
|
||||
"""
|
||||
Helper function to calls the `_uniquity_file` function. Takes in directory and file name
|
||||
to check on.
|
||||
"""
|
||||
filename = _uniquity_file(os.listdir(dir), filename)
|
||||
return filename
|
||||
|
||||
|
||||
def _mean(scores: Union[pd.Series, List[float]], rounding: Optional[int] = 3) -> Union[float, None]:
|
||||
"""
|
||||
Find mean from the list. Returns None if no element in the list.
|
||||
|
||||
Args:
|
||||
rounding (int): optional argument that allows user to define decimal points. Default at 3.
|
||||
"""
|
||||
if len(scores) == 0:
|
||||
return None
|
||||
mean = statistics.mean(scores)
|
||||
if not rounding:
|
||||
return mean
|
||||
return round(mean, rounding)
|
||||
|
||||
|
||||
def _stdev(scores: List[Optional[float]], rounding: Optional[int] = 3) -> Union[float, None]:
|
||||
"""
|
||||
Find standard deviation from the list.
|
||||
Returns None if only 0 or 1 element in the list.
|
||||
|
||||
Args:
|
||||
rounding (int): optional argument that allows user to define decimal points. Default at 3.
|
||||
"""
|
||||
# Filter out None values
|
||||
scores = [score for score in scores if score is not None]
|
||||
# Proceed only if there are more than one value
|
||||
if len(scores) <= 1:
|
||||
return None
|
||||
if not rounding:
|
||||
return statistics.stdev(scores)
|
||||
return round(statistics.stdev(scores), rounding)
|
||||
|
||||
|
||||
def _pstdev(scores: List[Optional[float]], rounding: Optional[int] = 3) -> Union[float, None]:
|
||||
"""
|
||||
Find population standard deviation from the list.
|
||||
Returns None if only 0 or 1 element in the list.
|
||||
|
||||
Args:
|
||||
rounding (int): optional argument that allows user to define decimal points. Default at 3.
|
||||
"""
|
||||
scores = [score for score in scores if score is not None]
|
||||
if len(scores) <= 1:
|
||||
return None
|
||||
if not rounding:
|
||||
return statistics.pstdev(scores)
|
||||
return round(statistics.pstdev(scores), rounding)
|
||||
|
||||
|
||||
def _count(scores: List[Optional[float]]) -> float:
|
||||
"""
|
||||
Returns the row count of the list.
|
||||
"""
|
||||
return len(scores)
|
||||
|
||||
|
||||
def _read_text_file(path):
|
||||
"""
|
||||
Reads the contents of a text file and returns it as a string.
|
||||
"""
|
||||
# Check if the file exists
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"The file at {path} does not exist.")
|
||||
|
||||
try:
|
||||
with open(path, errors="ignore") as f:
|
||||
text = f.read()
|
||||
return text
|
||||
except OSError as e:
|
||||
# Handle other I/O related errors
|
||||
raise IOError(f"An error occurred when reading the file at {path}: {e}")
|
||||
Reference in New Issue
Block a user