indico_toolkit 6.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- indico_toolkit/__init__.py +24 -0
- indico_toolkit/association/__init__.py +15 -0
- indico_toolkit/association/association.py +65 -0
- indico_toolkit/association/extracted_tokens.py +106 -0
- indico_toolkit/association/line_items.py +189 -0
- indico_toolkit/association/positioning.py +301 -0
- indico_toolkit/association/splitting.py +41 -0
- indico_toolkit/auto_populate/__init__.py +3 -0
- indico_toolkit/auto_populate/populator.py +362 -0
- indico_toolkit/auto_populate/types.py +66 -0
- indico_toolkit/auto_review/__init__.py +6 -0
- indico_toolkit/auto_review/auto_review_functions.py +123 -0
- indico_toolkit/auto_review/auto_reviewer.py +62 -0
- indico_toolkit/client.py +35 -0
- indico_toolkit/errors.py +32 -0
- indico_toolkit/etloutput/__init__.py +210 -0
- indico_toolkit/etloutput/cell.py +47 -0
- indico_toolkit/etloutput/errors.py +16 -0
- indico_toolkit/etloutput/etloutput.py +110 -0
- indico_toolkit/etloutput/range.py +30 -0
- indico_toolkit/etloutput/table.py +44 -0
- indico_toolkit/etloutput/token.py +25 -0
- indico_toolkit/indico_wrapper/__init__.py +15 -0
- indico_toolkit/indico_wrapper/dataset.py +114 -0
- indico_toolkit/indico_wrapper/doc_extraction.py +79 -0
- indico_toolkit/indico_wrapper/download.py +193 -0
- indico_toolkit/indico_wrapper/indico_wrapper.py +119 -0
- indico_toolkit/indico_wrapper/modelop.py +200 -0
- indico_toolkit/indico_wrapper/reviewer.py +111 -0
- indico_toolkit/indico_wrapper/workflow.py +231 -0
- indico_toolkit/metrics/__init__.py +11 -0
- indico_toolkit/metrics/compare_ground_truth.py +123 -0
- indico_toolkit/metrics/compare_models.py +180 -0
- indico_toolkit/metrics/metrics.py +373 -0
- indico_toolkit/metrics/plotting.py +102 -0
- indico_toolkit/ocr/__init__.py +9 -0
- indico_toolkit/ocr/customocr_object.py +42 -0
- indico_toolkit/ocr/ondoc_object.py +93 -0
- indico_toolkit/ocr/standard_object.py +54 -0
- indico_toolkit/pipelines/__init__.py +3 -0
- indico_toolkit/pipelines/file_processing.py +157 -0
- indico_toolkit/polling/__init__.py +8 -0
- indico_toolkit/polling/autoreview.py +204 -0
- indico_toolkit/polling/downstream.py +145 -0
- indico_toolkit/polling/queries.py +68 -0
- indico_toolkit/py.typed +0 -0
- indico_toolkit/results/__init__.py +104 -0
- indico_toolkit/results/document.py +78 -0
- indico_toolkit/results/errors.py +4 -0
- indico_toolkit/results/model.py +60 -0
- indico_toolkit/results/normalization.py +159 -0
- indico_toolkit/results/predictionlist.py +395 -0
- indico_toolkit/results/predictions/__init__.py +88 -0
- indico_toolkit/results/predictions/box.py +58 -0
- indico_toolkit/results/predictions/citation.py +47 -0
- indico_toolkit/results/predictions/classification.py +50 -0
- indico_toolkit/results/predictions/documentextraction.py +160 -0
- indico_toolkit/results/predictions/extraction.py +35 -0
- indico_toolkit/results/predictions/formextraction.py +121 -0
- indico_toolkit/results/predictions/group.py +29 -0
- indico_toolkit/results/predictions/prediction.py +41 -0
- indico_toolkit/results/predictions/span.py +43 -0
- indico_toolkit/results/predictions/summarization.py +103 -0
- indico_toolkit/results/predictions/unbundling.py +51 -0
- indico_toolkit/results/result.py +152 -0
- indico_toolkit/results/review.py +32 -0
- indico_toolkit/results/utilities.py +73 -0
- indico_toolkit/retry.py +94 -0
- indico_toolkit/snapshots/__init__.py +3 -0
- indico_toolkit/snapshots/snapshot.py +340 -0
- indico_toolkit/structure/__init__.py +3 -0
- indico_toolkit/structure/create_structure.py +232 -0
- indico_toolkit/structure/queries.py +119 -0
- indico_toolkit/structure/utils.py +16 -0
- indico_toolkit/types/__init__.py +12 -0
- indico_toolkit/types/classification.py +92 -0
- indico_toolkit/types/extractions.py +227 -0
- indico_toolkit/types/predictions.py +28 -0
- indico_toolkit/types/workflow_object.py +133 -0
- indico_toolkit-6.14.0.dist-info/LICENSE +21 -0
- indico_toolkit-6.14.0.dist-info/METADATA +144 -0
- indico_toolkit-6.14.0.dist-info/RECORD +83 -0
- indico_toolkit-6.14.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from os import PathLike
|
|
3
|
+
from typing import Dict, List, Union
|
|
4
|
+
|
|
5
|
+
from indico import IndicoClient
|
|
6
|
+
from indico.queries import (
|
|
7
|
+
GetSubmission,
|
|
8
|
+
GetWorkflow,
|
|
9
|
+
JobStatus,
|
|
10
|
+
ListSubmissions,
|
|
11
|
+
Submission,
|
|
12
|
+
SubmissionFilter,
|
|
13
|
+
SubmitReview,
|
|
14
|
+
UpdateSubmission,
|
|
15
|
+
UpdateWorkflowSettings,
|
|
16
|
+
WaitForSubmissions,
|
|
17
|
+
WorkflowSubmission,
|
|
18
|
+
)
|
|
19
|
+
from indico.queries.submission import SubmissionResult
|
|
20
|
+
from indico.types import Workflow
|
|
21
|
+
|
|
22
|
+
from ..errors import ToolkitStatusError
|
|
23
|
+
from ..ocr import OnDoc
|
|
24
|
+
from ..types import WorkflowResult
|
|
25
|
+
from .indico_wrapper import IndicoWrapper
|
|
26
|
+
|
|
27
|
+
COMPLETE_FILTER = SubmissionFilter(status="COMPLETE", retrieved=False)
|
|
28
|
+
PENDING_REVIEW_FILTER = SubmissionFilter(status="PENDING_REVIEW", retrieved=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Workflow(IndicoWrapper):
|
|
32
|
+
"""
|
|
33
|
+
Class to support Workflow-related API calls
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, client: IndicoClient):
|
|
37
|
+
self.client = client
|
|
38
|
+
|
|
39
|
+
def get_workflow(self, workflow_id: int) -> Workflow:
|
|
40
|
+
"""
|
|
41
|
+
Args:
|
|
42
|
+
workflow_id (int): Workflow id to query for
|
|
43
|
+
"""
|
|
44
|
+
return self.client.call(GetWorkflow(workflow_id))
|
|
45
|
+
|
|
46
|
+
def submit_documents_to_workflow(
|
|
47
|
+
self,
|
|
48
|
+
workflow_id: int,
|
|
49
|
+
*,
|
|
50
|
+
files: Union[List[Union[str, PathLike]], None] = None,
|
|
51
|
+
streams: Union[Dict[str, io.BufferedIOBase], None] = None,
|
|
52
|
+
) -> List[int]:
|
|
53
|
+
"""
|
|
54
|
+
Args:
|
|
55
|
+
workflow_id (int): Workflow to submit to
|
|
56
|
+
pdf_filepaths (List[str]): Path to local documents you would like to submit
|
|
57
|
+
streams (Dict[str, io.BufferedIOBase]): List of filename keys mapped to
|
|
58
|
+
streams for upload.
|
|
59
|
+
Returns:
|
|
60
|
+
List[int]: List of unique and persistent identifier for each submission.
|
|
61
|
+
"""
|
|
62
|
+
return self.client.call(
|
|
63
|
+
WorkflowSubmission(workflow_id=workflow_id, files=files, streams=streams)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def get_ondoc_ocr_from_etl_url(self, etl_url: str) -> OnDoc:
|
|
67
|
+
"""
|
|
68
|
+
Get ondocument OCR object from workflow result etl output
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
etl_url (str): url from "etl_output" key of workflow result json
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
OnDoc: 'ondocument' OCR object
|
|
75
|
+
"""
|
|
76
|
+
ocr_result = []
|
|
77
|
+
etl_response = self.get_storage_object(etl_url)
|
|
78
|
+
for page in etl_response["pages"]:
|
|
79
|
+
page_ocr = self.get_storage_object(page["page_info"])
|
|
80
|
+
ocr_result.append(page_ocr)
|
|
81
|
+
return OnDoc(ocr_result)
|
|
82
|
+
|
|
83
|
+
def get_file_bytes(self, file_url: str) -> bytes:
|
|
84
|
+
return self.get_storage_object(file_url)
|
|
85
|
+
|
|
86
|
+
def get_img_bytes_from_etl_url(self, etl_url: str) -> List[bytes]:
|
|
87
|
+
"""
|
|
88
|
+
Get image bytes for each page from workflow result etl output
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
etl_url (str): url from "etl_output" key of workflow result json
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
image_bytes
|
|
95
|
+
"""
|
|
96
|
+
image_bytes = []
|
|
97
|
+
etl_response = self.get_storage_object(etl_url)
|
|
98
|
+
for page in etl_response["pages"]:
|
|
99
|
+
page_bytes = self.get_storage_object(page["image"])
|
|
100
|
+
image_bytes.append(page_bytes)
|
|
101
|
+
return image_bytes
|
|
102
|
+
|
|
103
|
+
def mark_submission_as_retreived(self, submission_id: int):
|
|
104
|
+
self.client.call(UpdateSubmission(submission_id, retrieved=True))
|
|
105
|
+
|
|
106
|
+
def get_complete_submission_objects(
|
|
107
|
+
self, workflow_id: int, submission_ids: List[int] = None
|
|
108
|
+
) -> List[Submission]:
|
|
109
|
+
return self._get_list_of_submissions(
|
|
110
|
+
workflow_id, COMPLETE_FILTER, submission_ids
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def get_submission_object(self, submission_id: int) -> Submission:
|
|
114
|
+
return self.client.call(GetSubmission(submission_id))
|
|
115
|
+
|
|
116
|
+
def get_submission_results_from_ids(
|
|
117
|
+
self,
|
|
118
|
+
submission_ids: List[int],
|
|
119
|
+
timeout: int = 180,
|
|
120
|
+
return_raw_json: bool = False,
|
|
121
|
+
raise_exception_for_failed: bool = False,
|
|
122
|
+
return_failed_results: bool = True,
|
|
123
|
+
ignore_deleted_submissions: bool = False,
|
|
124
|
+
) -> List[WorkflowResult]:
|
|
125
|
+
"""
|
|
126
|
+
Wait for submission to pass through workflow models and get result. If Review is
|
|
127
|
+
enabled, result may be retrieved prior to human review.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
submission_ids (List[int]): Ids of submission predictions to retrieve
|
|
131
|
+
timeout (int): seconds permitted for each submission prior to timing out
|
|
132
|
+
return_raw_json: (bool) = If True return raw json result, otherwise return
|
|
133
|
+
WorkflowResult object.
|
|
134
|
+
raise_exception_for_failed (bool): if True, ToolkitStatusError raised for
|
|
135
|
+
failed submissions
|
|
136
|
+
return_failed_results (bool): if True, return objects for failed submissions
|
|
137
|
+
ignore_deleted_submissions (bool): if True, ignore deleted submissions
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
List[WorkflowResult]: workflow result objects
|
|
141
|
+
"""
|
|
142
|
+
results = []
|
|
143
|
+
self.wait_for_submissions_to_process(submission_ids, timeout)
|
|
144
|
+
for subid in submission_ids:
|
|
145
|
+
submission: Submission = self.get_submission_object(subid)
|
|
146
|
+
if submission.status == "FAILED":
|
|
147
|
+
message = f"FAILURE, Submission: {subid}. {submission.errors}"
|
|
148
|
+
if raise_exception_for_failed:
|
|
149
|
+
raise ToolkitStatusError(message)
|
|
150
|
+
elif not return_failed_results:
|
|
151
|
+
print(message)
|
|
152
|
+
continue
|
|
153
|
+
if submission.deleted and ignore_deleted_submissions:
|
|
154
|
+
continue
|
|
155
|
+
result = self._create_result(submission)
|
|
156
|
+
# Add path to original input file to result
|
|
157
|
+
result["input_file"] = submission.input_file
|
|
158
|
+
result["filename"] = submission.input_filename
|
|
159
|
+
if return_raw_json:
|
|
160
|
+
results.append(result)
|
|
161
|
+
else:
|
|
162
|
+
results.append(WorkflowResult(result))
|
|
163
|
+
return results
|
|
164
|
+
|
|
165
|
+
def _create_result(self, submission: Union[Submission, int]):
|
|
166
|
+
"""
|
|
167
|
+
Assumes you already checked that the submission result is COMPLETE
|
|
168
|
+
"""
|
|
169
|
+
job = self.client.call(SubmissionResult(submission, wait=True))
|
|
170
|
+
return self.get_storage_object(job.result)
|
|
171
|
+
|
|
172
|
+
def submit_submission_review(
|
|
173
|
+
self,
|
|
174
|
+
submission_id: int,
|
|
175
|
+
updated_predictions: dict,
|
|
176
|
+
wait: bool = True,
|
|
177
|
+
force_complete: bool = False,
|
|
178
|
+
):
|
|
179
|
+
job = self.client.call(
|
|
180
|
+
SubmitReview(
|
|
181
|
+
submission_id,
|
|
182
|
+
changes=updated_predictions,
|
|
183
|
+
force_complete=force_complete,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
if wait:
|
|
187
|
+
job = self.client.call(JobStatus(job.id, wait=True))
|
|
188
|
+
return job
|
|
189
|
+
|
|
190
|
+
def update_workflow_settings(
|
|
191
|
+
self,
|
|
192
|
+
workflow_id: int,
|
|
193
|
+
enable_review: bool = False,
|
|
194
|
+
enable_auto_review: bool = False,
|
|
195
|
+
) -> None:
|
|
196
|
+
self.client.call(
|
|
197
|
+
UpdateWorkflowSettings(
|
|
198
|
+
workflow_id,
|
|
199
|
+
enable_review=enable_review,
|
|
200
|
+
enable_auto_review=enable_auto_review,
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def wait_for_submissions_to_process(
|
|
205
|
+
self, submission_ids: List[int], timeout_seconds: int = 180
|
|
206
|
+
) -> None:
|
|
207
|
+
"""
|
|
208
|
+
Wait for submissions to reach a terminal status of "COMPLETE",
|
|
209
|
+
"PENDING_AUTO_REVIEW", "FAILED", or "PENDING_REVIEW"
|
|
210
|
+
"""
|
|
211
|
+
self.client.call(WaitForSubmissions(submission_ids, timeout_seconds))
|
|
212
|
+
|
|
213
|
+
def _get_list_of_submissions(
|
|
214
|
+
self,
|
|
215
|
+
workflow_id: int,
|
|
216
|
+
submission_filter: SubmissionFilter,
|
|
217
|
+
submission_ids: List[int] = None,
|
|
218
|
+
) -> List[Submission]:
|
|
219
|
+
return self.client.call(
|
|
220
|
+
ListSubmissions(
|
|
221
|
+
workflow_ids=[workflow_id],
|
|
222
|
+
submission_ids=submission_ids,
|
|
223
|
+
filters=submission_filter,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def _error_handle(self, message: str, ignore_exceptions: bool):
|
|
228
|
+
if ignore_exceptions:
|
|
229
|
+
print(f"Ignoring exception and continuing: {message}")
|
|
230
|
+
else:
|
|
231
|
+
raise RuntimeError(message)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .compare_ground_truth import CompareGroundTruth
|
|
2
|
+
from .compare_models import CompareModels
|
|
3
|
+
from .metrics import ExtractionMetrics
|
|
4
|
+
from .plotting import Plotting
|
|
5
|
+
|
|
6
|
+
__all__ = (
|
|
7
|
+
"CompareGroundTruth",
|
|
8
|
+
"CompareModels",
|
|
9
|
+
"ExtractionMetrics",
|
|
10
|
+
"Plotting",
|
|
11
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from ..association.association import sequences_exact, sequences_overlap
|
|
4
|
+
from ..errors import ToolkitInputError
|
|
5
|
+
from ..types.extractions import Extractions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CompareGroundTruth:
|
|
9
|
+
"""
|
|
10
|
+
Compare a set of ground truths against a set of model predictions on a per document
|
|
11
|
+
basis.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, ground_truth: List[dict], predictions: List[dict]):
|
|
15
|
+
self.gt_by_label: dict = Extractions(ground_truth).to_dict_by_label
|
|
16
|
+
self.preds_by_label: dict = Extractions(predictions).to_dict_by_label
|
|
17
|
+
self.labels: List[str] = list(
|
|
18
|
+
set(list(self.gt_by_label) + list(self.preds_by_label))
|
|
19
|
+
)
|
|
20
|
+
self.all_label_metrics: dict = None
|
|
21
|
+
self.overall_metrics: dict = None
|
|
22
|
+
|
|
23
|
+
def set_all_label_metrics(self, span_type: str = "overlap") -> None:
|
|
24
|
+
"""
|
|
25
|
+
The "all_label_metrics" dict includes each label as a key and each label's
|
|
26
|
+
metrics as the corresponding value.
|
|
27
|
+
"""
|
|
28
|
+
if span_type in ["exact", "overlap"]:
|
|
29
|
+
self.all_label_metrics = {
|
|
30
|
+
label: self._get_base_metrics(label, span_type) for label in self.labels
|
|
31
|
+
}
|
|
32
|
+
else:
|
|
33
|
+
raise ToolkitInputError("Invalid span type entered.")
|
|
34
|
+
|
|
35
|
+
def set_overall_metrics(self) -> None:
|
|
36
|
+
"""
|
|
37
|
+
The "overall_metrics" dict includes the metrics for the entire document.
|
|
38
|
+
(Key: metric type; value: metric value)
|
|
39
|
+
"""
|
|
40
|
+
if self.all_label_metrics is None:
|
|
41
|
+
self.set_all_label_metrics()
|
|
42
|
+
|
|
43
|
+
metrics_types = [
|
|
44
|
+
"true_positives",
|
|
45
|
+
"false_positives",
|
|
46
|
+
"false_negatives",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
overall_metrics = {}
|
|
50
|
+
|
|
51
|
+
for metric in metrics_types:
|
|
52
|
+
total_amt = 0
|
|
53
|
+
for label in self.all_label_metrics:
|
|
54
|
+
total_amt += self.all_label_metrics[label][metric]
|
|
55
|
+
overall_metrics[metric] = total_amt
|
|
56
|
+
|
|
57
|
+
overall_metrics["precision"] = self._get_precision(
|
|
58
|
+
overall_metrics["true_positives"],
|
|
59
|
+
overall_metrics["false_positives"],
|
|
60
|
+
)
|
|
61
|
+
overall_metrics["recall"] = self._get_recall(
|
|
62
|
+
overall_metrics["true_positives"],
|
|
63
|
+
overall_metrics["false_negatives"],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self.overall_metrics = overall_metrics
|
|
67
|
+
|
|
68
|
+
def _get_precision(self, true_p: int, false_p: int) -> float:
|
|
69
|
+
try:
|
|
70
|
+
precision = true_p / (true_p + false_p)
|
|
71
|
+
except ZeroDivisionError:
|
|
72
|
+
precision = 0
|
|
73
|
+
return precision
|
|
74
|
+
|
|
75
|
+
def _get_recall(self, true_p: int, false_n: int) -> float:
|
|
76
|
+
try:
|
|
77
|
+
recall = true_p / (true_p + false_n)
|
|
78
|
+
except ZeroDivisionError:
|
|
79
|
+
recall = 0
|
|
80
|
+
return recall
|
|
81
|
+
|
|
82
|
+
def _get_base_metrics(self, label: str, span_type: str) -> dict:
|
|
83
|
+
"""
|
|
84
|
+
With the current overlap span type calculation, if 2 separate predictions
|
|
85
|
+
each overlap with a single ground truth, each pred is counted as a true
|
|
86
|
+
positive. (i.e. There isn't a break out of the loop once a TP is found.)
|
|
87
|
+
"""
|
|
88
|
+
# TODO potentially build in choice on the "multiple true positives" per ground
|
|
89
|
+
# truth prediction by adding conditional
|
|
90
|
+
true_pos = 0
|
|
91
|
+
false_neg = 0
|
|
92
|
+
false_pos = 0
|
|
93
|
+
span_types = {"overlap": sequences_overlap, "exact": sequences_exact}
|
|
94
|
+
span_type_func = span_types[span_type]
|
|
95
|
+
|
|
96
|
+
if label not in self.preds_by_label:
|
|
97
|
+
false_neg = len(self.gt_by_label[label])
|
|
98
|
+
elif label not in self.gt_by_label:
|
|
99
|
+
false_pos = len(self.preds_by_label[label])
|
|
100
|
+
else:
|
|
101
|
+
for model_pred in self.preds_by_label[label]:
|
|
102
|
+
match_found = False
|
|
103
|
+
for gt_pred in self.gt_by_label[label]:
|
|
104
|
+
if span_type_func(model_pred, gt_pred):
|
|
105
|
+
true_pos += 1
|
|
106
|
+
match_found = True
|
|
107
|
+
if not match_found:
|
|
108
|
+
false_pos += 1
|
|
109
|
+
for gt_pred in self.gt_by_label[label]:
|
|
110
|
+
match_found = False
|
|
111
|
+
for model_pred in self.preds_by_label[label]:
|
|
112
|
+
if span_type_func(model_pred, gt_pred):
|
|
113
|
+
match_found = True
|
|
114
|
+
if not match_found:
|
|
115
|
+
false_neg += 1
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"true_positives": true_pos,
|
|
119
|
+
"false_negatives": false_neg,
|
|
120
|
+
"false_positives": false_pos,
|
|
121
|
+
"precision": self._get_precision(true_pos, false_pos),
|
|
122
|
+
"recall": self._get_recall(true_pos, false_neg),
|
|
123
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from collections import namedtuple
|
|
2
|
+
from typing import List, Set, Tuple
|
|
3
|
+
|
|
4
|
+
from indico import IndicoClient
|
|
5
|
+
|
|
6
|
+
from ..errors import ToolkitInputError
|
|
7
|
+
from .metrics import ExtractionMetrics
|
|
8
|
+
from .plotting import Plotting
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
_PANDAS_INSTALLED = True
|
|
14
|
+
except ImportError as error:
|
|
15
|
+
_PANDAS_INSTALLED = False
|
|
16
|
+
_IMPORT_ERROR = error
|
|
17
|
+
|
|
18
|
+
ModelIds = namedtuple("ModelIds", "group_id id")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CompareModels(ExtractionMetrics):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
client: IndicoClient,
|
|
25
|
+
model_group_1: int,
|
|
26
|
+
model_id_1: int,
|
|
27
|
+
model_group_2: int,
|
|
28
|
+
model_id_2: int,
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Compare the metrics for two extraction models
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
client (IndicoClient): instantiated indico client
|
|
35
|
+
model_group_1 (int): model group of first model
|
|
36
|
+
model_id_1 (int): id of first model
|
|
37
|
+
model_group_2 (int): model group of second model
|
|
38
|
+
model_id_2 (int): id of second model
|
|
39
|
+
"""
|
|
40
|
+
if not _PANDAS_INSTALLED:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"comparing metrics requires additional dependencies: "
|
|
43
|
+
"`pip install indico-toolkit[metrics]`"
|
|
44
|
+
) from _IMPORT_ERROR
|
|
45
|
+
|
|
46
|
+
self.client = client
|
|
47
|
+
self.models = [
|
|
48
|
+
ModelIds(model_group_1, model_id_1),
|
|
49
|
+
ModelIds(model_group_2, model_id_2),
|
|
50
|
+
]
|
|
51
|
+
self.non_overlapping_fields: Set[str] = None
|
|
52
|
+
self.overlapping_fields: Set[str] = None
|
|
53
|
+
self.df: pd.DataFrame = None
|
|
54
|
+
|
|
55
|
+
def get_data(self, span_type: str = "overlap") -> "pd.DataFrame":
|
|
56
|
+
"""
|
|
57
|
+
Gathers metrics for both models into a dataframe, setting it to self.df
|
|
58
|
+
Args:
|
|
59
|
+
span_type (str): options include 'superset', 'exact', 'overlap' or 'token'
|
|
60
|
+
"""
|
|
61
|
+
dfs = []
|
|
62
|
+
for model in self.models:
|
|
63
|
+
self.get_metrics(model.group_id)
|
|
64
|
+
df = self.get_metrics_df(span_type, model.id)
|
|
65
|
+
df.drop("model_id", axis=1, inplace=True)
|
|
66
|
+
dfs.append(df)
|
|
67
|
+
self._set_labelset_info(dfs)
|
|
68
|
+
if len(self.overlapping_fields) == 0:
|
|
69
|
+
raise ToolkitInputError(
|
|
70
|
+
"There are no shared labels between the models you provided: "
|
|
71
|
+
f"{self.non_overlapping_fields}"
|
|
72
|
+
)
|
|
73
|
+
self.df = pd.merge(
|
|
74
|
+
dfs[0], dfs[1], on="field_name", suffixes=self._model_suffixes
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def get_metric_differences(
|
|
78
|
+
self, metric: str = "f1Score", include_difference: bool = True
|
|
79
|
+
):
|
|
80
|
+
"""
|
|
81
|
+
Get a dataframe focused on one metrics, by default sorted by a column of value
|
|
82
|
+
differences
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
metric (str, optional): possible values are 'precision', 'recall',
|
|
86
|
+
'f1Score', 'falsePositives', 'falseNegatives', 'truePositives'.
|
|
87
|
+
Defaults to "f1Score".
|
|
88
|
+
include_difference (bool): include a column of the most recent model ID
|
|
89
|
+
minus the older model ID
|
|
90
|
+
"""
|
|
91
|
+
metric_cols = self._get_metric_col_names(metric)
|
|
92
|
+
cols_to_keep = ["field_name", *metric_cols]
|
|
93
|
+
diff_df = self.df[cols_to_keep].copy()
|
|
94
|
+
if include_difference:
|
|
95
|
+
diff_df["difference"] = diff_df[metric_cols[0]] - diff_df[metric_cols[1]]
|
|
96
|
+
diff_df = diff_df.sort_values(by=["difference"], ascending=False)
|
|
97
|
+
return diff_df
|
|
98
|
+
|
|
99
|
+
def bar_plot(
|
|
100
|
+
self,
|
|
101
|
+
output_path: str,
|
|
102
|
+
metric: str = "f1Score",
|
|
103
|
+
plot_title: str = "",
|
|
104
|
+
bar_colors: List[str] = ["salmon", "darkblue"],
|
|
105
|
+
):
|
|
106
|
+
"""
|
|
107
|
+
Write an html bar plot to disc. Will also open the plot automatically in your
|
|
108
|
+
browser, where you will interactive functionality and the ability to download a
|
|
109
|
+
copy as a PNG as well.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
output_path (str): where you want to write plot, e.g. "./myplot.html"
|
|
113
|
+
metric (str, optional): possible values are 'precision', 'recall',
|
|
114
|
+
'f1Score', 'falsePositives', 'falseNegatives', 'truePositives'.
|
|
115
|
+
Defaults to "f1Score".
|
|
116
|
+
plot_title (str, optional): Title of the plot. Defaults to "".
|
|
117
|
+
bar_colors (List[str], optional): length two list with the colors for your
|
|
118
|
+
plot, can be css color names, rgb, or hex name .
|
|
119
|
+
Defaults to ["#EEE8AA", "#98FB98"].
|
|
120
|
+
"""
|
|
121
|
+
metric_cols = self._get_metric_col_names(metric, order_descending=False)
|
|
122
|
+
plotting = Plotting()
|
|
123
|
+
for i in range(2):
|
|
124
|
+
plotting.add_barplot_data(
|
|
125
|
+
self.df["field_name"],
|
|
126
|
+
self.df[metric_cols[i]],
|
|
127
|
+
name=self._id_from_col_name(metric_cols[i]),
|
|
128
|
+
color=bar_colors[i],
|
|
129
|
+
)
|
|
130
|
+
plotting.define_layout(
|
|
131
|
+
yaxis_title=metric, legend_title="Model ID", plot_title=plot_title
|
|
132
|
+
)
|
|
133
|
+
plotting.plot(output_path)
|
|
134
|
+
|
|
135
|
+
def to_csv(
|
|
136
|
+
self,
|
|
137
|
+
output_path: str,
|
|
138
|
+
):
|
|
139
|
+
"""
|
|
140
|
+
Write a CSV to disc of all model metrics
|
|
141
|
+
Args:
|
|
142
|
+
output_path (str): path to write CSV on your system, e.g. "./my_metrics.csv"
|
|
143
|
+
"""
|
|
144
|
+
self.df.to_csv(output_path, index=False)
|
|
145
|
+
|
|
146
|
+
def get_data_df(self):
|
|
147
|
+
raise NotImplementedError(
|
|
148
|
+
"This method is not used by this child class. Use 'df' attribute"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _id_from_col_name(col_name: str):
|
|
153
|
+
return col_name.split("_")[-1]
|
|
154
|
+
|
|
155
|
+
def _get_metric_col_names(self, metric: str, order_descending: bool = True):
|
|
156
|
+
ordered = sorted(
|
|
157
|
+
self._model_suffixes,
|
|
158
|
+
key=lambda x: int(x.replace("_", "")),
|
|
159
|
+
reverse=order_descending,
|
|
160
|
+
)
|
|
161
|
+
return [f"{metric}{suffix}" for suffix in ordered]
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def labelsets_overlap(labelset1: set, labelset2: set):
|
|
165
|
+
return labelset1.intersection(labelset2)
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def labelset_differences(labelset1: set, labelset2: set):
|
|
169
|
+
return labelset1.symmetric_difference(labelset2)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def _model_suffixes(self):
|
|
173
|
+
return f"_{self.models[0].id}", f"_{self.models[1].id}"
|
|
174
|
+
|
|
175
|
+
def _set_labelset_info(self, df_list: "List[pd.DataFrame]") -> Tuple[set]:
|
|
176
|
+
labelsets = [set(i["field_name"]) for i in df_list]
|
|
177
|
+
self.overlapping_fields = self.labelsets_overlap(labelsets[0], labelsets[1])
|
|
178
|
+
self.non_overlapping_fields = self.labelset_differences(
|
|
179
|
+
labelsets[0], labelsets[1]
|
|
180
|
+
)
|