Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 65 additions & 20 deletions gliner/evaluation/evaluator.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import warnings
from collections import defaultdict
from typing import Union, List, Literal

import pandas as pd

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The pandas library is imported here but it is not used within this file. It appears to be used in gliner/model.py for the beautiful_df_print function, where it is also correctly imported. To maintain clean code and avoid unnecessary dependencies in this module, this import should be removed.

import numpy as np
import torch

Expand All @@ -24,7 +24,7 @@ def _prf_divide(
result = np.true_divide(numerator, denominator)
result[denominator == 0] = 0.0 if zero_division in ["warn", 0] else 1.0

if denominator == 0 and zero_division == "warn" and metric in warn_for:
if np.any(denominator == 0) and zero_division == "warn" and metric in warn_for:
msg_start = f"{metric.title()}"
if "f-score" in warn_for:
msg_start += " and F-score" if metric in warn_for else "F-score"
Comment on lines +27 to 30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Rounding in _prf_divide reduces numeric precision and pushes formatting concerns into a low‑level helper.

Previously this helper returned raw float/array values and left rounding to the presentation layer. Rounding to 2 decimals here means all downstream uses (including F-score, which already rounds) now operate on truncated values, increasing the risk of compounded rounding errors (e.g., micro scores derived from already-rounded per-class metrics) and reducing reusability for callers that need full precision. It would be safer to return the unrounded result and apply rounding only in the final user-facing layer (e.g., _calculate_metrics or printing).

Suggested implementation:

    return result

To fully follow your suggestion, you will also need to:

  1. Identify the user-facing layer(s) that consume _prf_divide (e.g., _calculate_metrics, reporting/printing functions).
  2. Apply rounding there, just before presenting values to the user (e.g., np.round(metric_values, 2) or appropriate formatting), ensuring that internal computations (micro/macro averaging, aggregations) continue to use full-precision results from _prf_divide.

Expand All @@ -36,7 +36,7 @@ def _prf_divide(
result_size=len(result),
)

return result
return np.round(result,decimals=2)


def _warn_prf(average: str, modifier: str, msg_start: str, result_size: int):
Expand Down Expand Up @@ -84,19 +84,44 @@ def flatten_for_eval(y_true, y_pred):
return all_true, all_pred


def compute_prf(y_true, y_pred, average="micro"):
def compute_prf(y_true:List,
y_pred:List,
):
all_metrics = {}
y_true, y_pred = flatten_for_eval(y_true, y_pred)

pred_sum, tp_sum, true_sum, target_names = extract_tp_actual_correct(y_true, y_pred)

if average == "micro":
tp_sum = np.array([tp_sum.sum()])
pred_sum = np.array([pred_sum.sum()])
true_sum = np.array([true_sum.sum()])

# Calculate macro metric (divide by classes number)
per_class_dico,metrics_macro = _calculate_metrics(tp_sum,pred_sum,true_sum,"macro")
all_metrics["macro"] = metrics_macro

# # Calculate performance per class
all_metrics["per_class"] = {target_names[i]:{"precision":per_class_dico["precision"][i],
"recall":per_class_dico["recall"][i],
"f_score":per_class_dico["f_score"][i]}
for i in range(len(target_names))
}


# Calculate micro metric (divide by all sum-up values of all classes )
tp_sum_micro = np.array([tp_sum.sum()])
pred_sum_micro = np.array([pred_sum.sum()])
true_sum_micro = np.array([true_sum.sum()])

metrics_micro = _calculate_metrics(tp_sum_micro,pred_sum_micro,true_sum_micro,"micro")
all_metrics["micro"] = metrics_micro

return all_metrics

def _calculate_metrics(tp,
pred,
true,
average : Literal["micro","macro"],
):
precision = _prf_divide(
numerator=tp_sum,
denominator=pred_sum,
numerator=tp,
denominator=pred,
metric="precision",
modifier="predicted",
Comment on lines +117 to 126

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Macro averages are simple means over all classes, which may be skewed by classes with zero support.

In the average == "macro" branch, precision/recall/F1 are averaged as precision.sum() / len(precision), which gives equal weight to classes even when they have zero support (no true or predicted instances, depending on _prf_divide). That means unseen labels still affect the macro score. Please confirm this matches your intended definition of macro metrics; if not, consider excluding zero-support classes from the average or switching to a support-weighted scheme.

average=average,
Expand All @@ -105,20 +130,30 @@ def compute_prf(y_true, y_pred, average="micro"):
)

recall = _prf_divide(
numerator=tp_sum,
denominator=true_sum,
numerator=tp, # TODO check

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a TODO check comment remaining in the code. The logic here, using tp as the numerator for recall, is correct. This leftover comment should be removed to clean up the code.

Suggested change
numerator=tp, # TODO check
numerator=tp,

denominator=true,
metric="recall",
modifier="true",
average=average,
warn_for=["precision", "recall", "f-score"],
zero_division="warn",
)

denominator = precision + recall
denominator[denominator == 0.0] = 1
f_score = 2 * (precision * recall) / denominator
denominator_fscore = precision + recall
denominator_fscore[denominator_fscore== 0.0] = 1
f_score = np.round(2 * (precision* recall) / denominator_fscore, decimals=2)

if average == "micro" :

return {"precision": precision[0], "recall": recall[0], "f_score": f_score[0]}

else :
per_class_array = {"precision": precision, "recall": recall, "f_score": f_score}
macro_precision = np.round(precision.sum()/len(precision),decimals=2)
macro_recall = np.round(recall.sum()/len(recall),decimals=2)
macro_f_score = np.round(f_score.sum()/ len(f_score), decimals=2)
Comment on lines +152 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

These calculations can lead to a ZeroDivisionError if precision, recall, or f_score are empty arrays. This would happen if there are no predictable entity types in the evaluation data, causing the program to crash. Using np.mean with np.nan_to_num provides a more robust way to calculate the macro average, gracefully handling the empty case by resulting in 0.0.

Suggested change
macro_precision = np.round(precision.sum()/len(precision),decimals=2)
macro_recall = np.round(recall.sum()/len(recall),decimals=2)
macro_f_score = np.round(f_score.sum()/ len(f_score), decimals=2)
macro_precision = np.round(np.nan_to_num(np.mean(precision)), decimals=2)
macro_recall = np.round(np.nan_to_num(np.mean(recall)), decimals=2)
macro_f_score = np.round(np.nan_to_num(np.mean(f_score)), decimals=2)

return per_class_array,{"precision": macro_precision, "recall": macro_recall, "f_score": macro_f_score}
Comment on lines +150 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle empty label sets to avoid ZeroDivisionError.
If there are no labels (no true/pred entities), len(precision) is 0 and macro aggregation crashes. Please return zeros (or NaNs) in this edge case.

🛠️ Suggested guard for empty metrics
     else : 
         per_class_array = {"precision": precision, "recall": recall, "f_score": f_score}
-        macro_precision = np.round(precision.sum()/len(precision),decimals=2)
-        macro_recall = np.round(recall.sum()/len(recall),decimals=2)
-        macro_f_score = np.round(f_score.sum()/ len(f_score), decimals=2)
+        if len(precision) == 0:
+            return per_class_array, {"precision": 0.0, "recall": 0.0, "f_score": 0.0}
+        macro_precision = np.round(precision.sum()/len(precision),decimals=2)
+        macro_recall = np.round(recall.sum()/len(recall),decimals=2)
+        macro_f_score = np.round(f_score.sum()/ len(f_score), decimals=2)
         return per_class_array,{"precision": macro_precision, "recall": macro_recall, "f_score": macro_f_score}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
else :
per_class_array = {"precision": precision, "recall": recall, "f_score": f_score}
macro_precision = np.round(precision.sum()/len(precision),decimals=2)
macro_recall = np.round(recall.sum()/len(recall),decimals=2)
macro_f_score = np.round(f_score.sum()/ len(f_score), decimals=2)
return per_class_array,{"precision": macro_precision, "recall": macro_recall, "f_score": macro_f_score}
else :
per_class_array = {"precision": precision, "recall": recall, "f_score": f_score}
if len(precision) == 0:
return per_class_array, {"precision": 0.0, "recall": 0.0, "f_score": 0.0}
macro_precision = np.round(precision.sum()/len(precision),decimals=2)
macro_recall = np.round(recall.sum()/len(recall),decimals=2)
macro_f_score = np.round(f_score.sum()/ len(f_score), decimals=2)
return per_class_array,{"precision": macro_precision, "recall": macro_recall, "f_score": macro_f_score}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gliner/evaluation/evaluator.py` around lines 150 - 155, The current macro
aggregation divides by len(precision) which raises ZeroDivisionError when there
are no labels; inside the same else branch guard on an empty metrics array
(e.g., if len(precision) == 0 or precision.size == 0) and set macro_precision,
macro_recall, macro_f_score to 0.0 (or np.nan if you prefer) instead of
computing the division, then return per_class_array (which may be empty) and the
macro dict; reference the variables precision, recall, f_score and the returned
per_class_array to locate where to add the guard.


return {"precision": precision[0], "recall": recall[0], "f_score": f_score[0]}


class Evaluator:
Expand Down Expand Up @@ -150,10 +185,20 @@ def transform_data(self):

@torch.no_grad()
def evaluate(self):
"""
output : {

"per_class":{"tag1":{"precision":int, "recall":int,"f_score":int},
"tag2":{}...
},
"micro":{"precision":int, "recall":int,"f_score":int},
"macro":{"precision":int, "recall":int,"f_score":int},
}
"""
Comment on lines +188 to +197

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The docstring incorrectly specifies int as the data type for precision, recall, and f-score. These metrics are floating-point numbers. Updating the type to float will make the documentation accurate and more helpful for developers using this function.

Suggested change
"""
output : {
"per_class":{"tag1":{"precision":int, "recall":int,"f_score":int},
"tag2":{}...
},
"micro":{"precision":int, "recall":int,"f_score":int},
"macro":{"precision":int, "recall":int,"f_score":int},
}
"""
"""
output : {
"per_class":{"tag1":{"precision":float, "recall":float,"f_score":float},
"tag2":{}...
},
"micro":{"precision":float, "recall":float,"f_score":float},
"macro":{"precision":float, "recall":float,"f_score":float},
}
"""

all_true_typed, all_outs_typed = self.transform_data()
precision, recall, f1 = compute_prf(all_true_typed, all_outs_typed).values()
output_str = f"P: {precision:.2%}\tR: {recall:.2%}\tF1: {f1:.2%}\n"
return output_str, f1
results_all = compute_prf(all_true_typed, all_outs_typed)
# output_str = f"P: {precision:.2%}\tR: {recall:.2%}\tF1: {f1:.2%}\n"
return results_all
Comment on lines +188 to +201

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update the docstring to reflect float metrics and dict output.
The new return value is a dict of rounded floats, but the docstring currently documents ints.

✏️ Docstring tweak
-        "per_class":{"tag1":{"precision":int, "recall":int,"f_score":int},
+        "per_class":{"tag1":{"precision":float, "recall":float,"f_score":float},
                     "tag2":{}...
                     },
-        "micro":{"precision":int, "recall":int,"f_score":int},
-        "macro":{"precision":int, "recall":int,"f_score":int},
+        "micro":{"precision":float, "recall":float,"f_score":float},
+        "macro":{"precision":float, "recall":float,"f_score":float},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"""
output : {
"per_class":{"tag1":{"precision":int, "recall":int,"f_score":int},
"tag2":{}...
},
"micro":{"precision":int, "recall":int,"f_score":int},
"macro":{"precision":int, "recall":int,"f_score":int},
}
"""
all_true_typed, all_outs_typed = self.transform_data()
precision, recall, f1 = compute_prf(all_true_typed, all_outs_typed).values()
output_str = f"P: {precision:.2%}\tR: {recall:.2%}\tF1: {f1:.2%}\n"
return output_str, f1
results_all = compute_prf(all_true_typed, all_outs_typed)
# output_str = f"P: {precision:.2%}\tR: {recall:.2%}\tF1: {f1:.2%}\n"
return results_all
"""
output : {
"per_class":{"tag1":{"precision":float, "recall":float,"f_score":float},
"tag2":{}...
},
"micro":{"precision":float, "recall":float,"f_score":float},
"macro":{"precision":float, "recall":float,"f_score":float},
}
"""
all_true_typed, all_outs_typed = self.transform_data()
results_all = compute_prf(all_true_typed, all_outs_typed)
# output_str = f"P: {precision:.2%}\tR: {recall:.2%}\tF1: {f1:.2%}\n"
return results_all
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gliner/evaluation/evaluator.py` around lines 188 - 201, The docstring for the
method currently claims integer metrics but the method returns a dict of rounded
floats (the variable results_all returned from compute_prf after
transform_data). Update the docstring to state that the return value is a dict
containing per_class, micro and macro entries with float
precision/recall/f_score (rounded floats), and show the exact dict structure and
example types (e.g. "precision": float) to match compute_prf/results_all;
mention transform_data and compute_prf in the description so readers can trace
the data flow.



def is_nested(idx1, idx2):
Expand Down
37 changes: 35 additions & 2 deletions gliner/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import re
import warnings
import pandas as pd

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate common dependency manifests and check for pandas.
echo "=== Searching for dependency manifests and pandas ==="
files=$(find . -maxdepth 3 -type f \( -name 'requirements*.txt' -o -name 'pyproject.toml' -o -name 'setup.cfg' -o -name 'setup.py' -o -name 'Pipfile' -o -name 'environment.yml' \) 2>/dev/null)
if [ -n "$files" ]; then
  echo "Found manifest files:"
  echo "$files"
  echo ""
  echo "=== Checking for pandas in these files ==="
  rg -n 'pandas' $files
else
  echo "No dependency manifest files found."
fi

Repository: arthrod/GLiNER

Length of output: 213


🏁 Script executed:

#!/bin/bash
# Check the actual import in gliner/model.py and the beautiful_df_print function
echo "=== First 20 lines of gliner/model.py ==="
head -20 gliner/model.py

echo ""
echo "=== Lines 663-676 of gliner/model.py (beautiful_df_print function) ==="
sed -n '663,676p' gliner/model.py

echo ""
echo "=== Check for all imports of pandas in gliner/model.py ==="
rg -n 'import.*pandas\|from.*pandas' gliner/model.py

Repository: arthrod/GLiNER

Length of output: 1318


🏁 Script executed:

#!/bin/bash
echo "=== pyproject.toml ==="
cat pyproject.toml

echo ""
echo "=== requirements.txt ==="
cat requirements.txt

Repository: arthrod/GLiNER

Length of output: 1533


Add pandas to dependencies or move the import inside beautiful_df_print.

Pandas is imported at module scope (line 5) but not declared in dependencies in pyproject.toml or requirements.txt. This breaks module loading for any user without pandas installed, even if they never call beautiful_df_print. Either add "pandas" to the dependencies list in pyproject.toml, or move the import inside the method with a helpful error message if pandas is not available.

Option: lazy-import inside the method
 def beautiful_df_print(self, eval_results:Dict):
+        try:
+            import pandas as pd
+        except ModuleNotFoundError as exc:
+            raise ModuleNotFoundError(
+                "pandas is required for beautiful_df_print. Please install it (e.g., pip install pandas)."
+            ) from exc
         """
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gliner/model.py` at line 5, The module-level "import pandas as pd" causes
import errors for users without pandas; either add "pandas" to project
dependencies in pyproject.toml/requirements.txt, or lazy-import it inside the
function beautiful_df_print in gliner.model: remove the top-level import, add
"import pandas as pd" at the start of beautiful_df_print, catch ImportError and
raise a clear RuntimeError (or ValueError) telling the user to install pandas,
and proceed to use pd as before—this localizes the dependency to callers of
beautiful_df_print and prevents breaking imports for other users.

from tqdm import tqdm
from pathlib import Path
from typing import Dict, List, Optional, Union
Expand Down Expand Up @@ -652,10 +653,42 @@ def evaluate(
all_trues.extend(batch["entities"])
# Evaluate the predictions
evaluator = Evaluator(all_trues, all_preds)
out, f1 = evaluator.evaluate()
# out, f1 = evaluator.evaluate()

return out, f1
# return out, f1
all_results = evaluator.evaluate()

return all_results
Comment on lines +656 to +661

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update evaluate() docs to match the new return type.
The function now returns a dict (not a tuple with F1), so the docstring should be updated accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gliner/model.py` around lines 656 - 661, The docstring for the evaluate()
method no longer matches its return type; update the evaluate() docstring (the
one for the Evaluator.evaluate / evaluate() method referenced by
evaluator.evaluate and the caller using all_results) to state that it returns a
dict of metrics/results (not a tuple with F1), describe the dict keys and value
types (e.g., overall F1, per-class metrics, any other entries), and remove any
mention of returning a tuple (out, f1) so callers and readers understand the new
return structure.


def beautiful_df_print(self, eval_results:Dict):
"""
eval_results :
{

"per_class":{"tag1":{"precision":int, "recall":int,"f_score":int},
"tag2":{}...
},
"micro":{"precision":int, "recall":int,"f_score":int},
"macro":{"precision":int, "recall":int,"f_score":int},
}
"""
Comment on lines +664 to +674

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The docstring indicates that the metric values (precision, recall, f_score) are of type int. However, these are floating-point numbers. The type hint should be float to accurately describe the data structure.

Suggested change
"""
eval_results :
{
"per_class":{"tag1":{"precision":int, "recall":int,"f_score":int},
"tag2":{}...
},
"micro":{"precision":int, "recall":int,"f_score":int},
"macro":{"precision":int, "recall":int,"f_score":int},
}
"""
"""
eval_results :
{
"per_class":{"tag1":{"precision":float, "recall":float,"f_score":float},
"tag2":{}...
},
"micro":{"precision":float, "recall":float,"f_score":float},
"macro":{"precision":float, "recall":float,"f_score":float},
}
"""

# add mico and macro metrics
df_metrics = pd.DataFrame()
df_metrics["MICRO_AVG"] = eval_results["micro"]
df_metrics["MACRO_AVG"] = eval_results["macro"]

# add seperator line
df_metrics = df_metrics.transpose()
df_metrics.loc["-------"]= {'precision': '---', 'recall': '---', 'f_score': '---'}


# add results per class
df_per_class = pd.DataFrame(eval_results["per_class"])
df_per_class = df_per_class.transpose().sort_values(by='f_score',ascending=False)

df = pd.concat([df_metrics,df_per_class])

return df
def encode_labels(self, labels: List[str], batch_size: int = 8) -> torch.FloatTensor:
"""
Embedding of labels.
Expand Down