evalring 0.2.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.
- EvalRing/__init__.py +106 -0
- EvalRing/agent/__init__.py +33 -0
- EvalRing/agent/base.py +122 -0
- EvalRing/agent/classification.py +191 -0
- EvalRing/agent/errors.py +109 -0
- EvalRing/agent/implementations.py +575 -0
- EvalRing/agent/multi_role.py +305 -0
- EvalRing/cli.py +284 -0
- EvalRing/config.py +216 -0
- EvalRing/dataset/__init__.py +8 -0
- EvalRing/dataset/ai_data_generator.py +217 -0
- EvalRing/dataset/base.py +238 -0
- EvalRing/dataset/implementations.py +179 -0
- EvalRing/evaluator/__init__.py +36 -0
- EvalRing/evaluator/base.py +165 -0
- EvalRing/evaluator/implementations.py +404 -0
- EvalRing/evaluator/llm_judge/__init__.py +32 -0
- EvalRing/evaluator/llm_judge/evaluator.py +588 -0
- EvalRing/evaluator/llm_judge/judge.py +138 -0
- EvalRing/evaluator/llm_judge/metric.py +194 -0
- EvalRing/evaluator/llm_judge/rubric.py +160 -0
- EvalRing/evaluator/llm_judge/schema.py +147 -0
- EvalRing/evaluator/llm_judge/template.py +144 -0
- EvalRing/logging_utils.py +66 -0
- EvalRing/py.typed +0 -0
- EvalRing/utils/__init__.py +16 -0
- EvalRing/utils/generate_model_list.py +155 -0
- EvalRing/utils/global_cache.py +531 -0
- EvalRing/utils/suite_runner.py +836 -0
- EvalRing/utils/timeout.py +48 -0
- EvalRing/utils/visualizations.py +560 -0
- evalring-0.2.0.dist-info/METADATA +454 -0
- evalring-0.2.0.dist-info/RECORD +37 -0
- evalring-0.2.0.dist-info/WHEEL +5 -0
- evalring-0.2.0.dist-info/entry_points.txt +2 -0
- evalring-0.2.0.dist-info/licenses/LICENSE +21 -0
- evalring-0.2.0.dist-info/top_level.txt +1 -0
EvalRing/config.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provider configuration and credential resolution.
|
|
3
|
+
|
|
4
|
+
EvalRing talks to any OpenAI-compatible chat-completions endpoint. Rather than
|
|
5
|
+
hard-coding a vendor, every component resolves its credentials through
|
|
6
|
+
:func:`resolve_credentials`, which reads a documented, ordered list of
|
|
7
|
+
environment variables.
|
|
8
|
+
|
|
9
|
+
See ``docs/CONFIGURATION.md`` for the full table and worked examples.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Final
|
|
17
|
+
|
|
18
|
+
#: Base URL applied when a provider is selected but no explicit URL is given.
|
|
19
|
+
#: ``None`` means "let the OpenAI SDK use its own default".
|
|
20
|
+
_PROVIDER_DEFAULT_BASE_URL: Final[dict[str, str | None]] = {
|
|
21
|
+
"evalring": None,
|
|
22
|
+
"openai": None,
|
|
23
|
+
"openrouter": "https://openrouter.ai/api/v1",
|
|
24
|
+
"radium": "https://api.radium.cloud/v1",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#: Ordered credential sources: (provider, api-key var, base-url var).
|
|
28
|
+
#: The first variable that is set wins. ``EVALRING_*`` is the vendor-neutral
|
|
29
|
+
#: form and should be preferred by new users; the remaining entries exist so
|
|
30
|
+
#: that existing provider-specific environments keep working unchanged.
|
|
31
|
+
_CREDENTIAL_SOURCES: Final[tuple[tuple[str, str, str], ...]] = (
|
|
32
|
+
("evalring", "EVALRING_API_KEY", "EVALRING_BASE_URL"),
|
|
33
|
+
("openai", "OPENAI_API_KEY", "OPENAI_BASE_URL"),
|
|
34
|
+
("openrouter", "OPENROUTER_API_KEY", "OPENROUTER_BASE_URL"),
|
|
35
|
+
("openrouter", "OPEN_ROUTER_KEY", "OPENROUTER_BASE_URL"),
|
|
36
|
+
("radium", "RADIUM_API_KEY", "RADIUM_BASE_URL"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
#: Ordered model-name variables, checked when no model is passed explicitly.
|
|
40
|
+
_MODEL_VARS: Final[tuple[str, ...]] = (
|
|
41
|
+
"EVALRING_MODEL",
|
|
42
|
+
"OPENAI_MODEL",
|
|
43
|
+
"OPENROUTER_MODEL",
|
|
44
|
+
"OPEN_ROUTER_MODEL",
|
|
45
|
+
"RADIUM_MODEL",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
#: Every variable EvalRing will read for an API key, in precedence order.
|
|
49
|
+
CREDENTIAL_ENV_VARS: Final[tuple[str, ...]] = tuple(
|
|
50
|
+
key_var for _, key_var, _ in _CREDENTIAL_SOURCES
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ProviderCredentials:
|
|
56
|
+
"""Resolved endpoint configuration for an OpenAI-compatible provider.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
api_key: The API key, or ``None`` when nothing was configured.
|
|
60
|
+
base_url: The endpoint to call, or ``None`` to use the SDK default
|
|
61
|
+
(``https://api.openai.com/v1``).
|
|
62
|
+
provider: Short identifier of the source that supplied the key, one of
|
|
63
|
+
``"evalring"``, ``"openai"``, ``"openrouter"``, ``"radium"``, or
|
|
64
|
+
``"explicit"`` when the key was passed in code.
|
|
65
|
+
source: Human-readable description of where the key came from, safe to
|
|
66
|
+
print in diagnostics (never contains the key itself).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
api_key: str | None
|
|
70
|
+
base_url: str | None
|
|
71
|
+
provider: str
|
|
72
|
+
source: str
|
|
73
|
+
|
|
74
|
+
def require_key(self) -> str:
|
|
75
|
+
"""Return the API key, raising a descriptive error when absent.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
MissingCredentialsError: If no API key was resolved.
|
|
79
|
+
"""
|
|
80
|
+
if not self.api_key:
|
|
81
|
+
raise MissingCredentialsError()
|
|
82
|
+
return self.api_key
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MissingCredentialsError(ValueError):
|
|
86
|
+
"""Raised when no API key could be resolved from arguments or environment."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, message: str | None = None) -> None:
|
|
89
|
+
super().__init__(
|
|
90
|
+
message
|
|
91
|
+
or (
|
|
92
|
+
"No API key found. Pass api_key=... explicitly, or set one of: "
|
|
93
|
+
+ ", ".join(CREDENTIAL_ENV_VARS)
|
|
94
|
+
+ ". For a non-OpenAI endpoint also set EVALRING_BASE_URL. "
|
|
95
|
+
"See docs/CONFIGURATION.md."
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _clean(value: str | None) -> str | None:
|
|
101
|
+
"""Normalize an environment value, treating blank strings as unset."""
|
|
102
|
+
if value is None:
|
|
103
|
+
return None
|
|
104
|
+
stripped = value.strip()
|
|
105
|
+
return stripped or None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve_credentials(
|
|
109
|
+
api_key: str | None = None,
|
|
110
|
+
base_url: str | None = None,
|
|
111
|
+
*,
|
|
112
|
+
env: dict[str, str] | None = None,
|
|
113
|
+
) -> ProviderCredentials:
|
|
114
|
+
"""Resolve the API key and base URL for an OpenAI-compatible provider.
|
|
115
|
+
|
|
116
|
+
Explicit arguments always win. Otherwise the variables listed in
|
|
117
|
+
``_CREDENTIAL_SOURCES`` are consulted in order; the first one that is set
|
|
118
|
+
determines both the key and, unless overridden, the base URL.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
api_key: Explicit API key. When given, no key variable is consulted.
|
|
122
|
+
base_url: Explicit endpoint. When given, no URL variable is consulted.
|
|
123
|
+
env: Environment mapping to read from. Defaults to ``os.environ``;
|
|
124
|
+
supplying one makes the function trivially testable.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A :class:`ProviderCredentials` describing the selected endpoint. The
|
|
128
|
+
``api_key`` field may be ``None``; call
|
|
129
|
+
:meth:`ProviderCredentials.require_key` at the point of use so the
|
|
130
|
+
error message is raised where it is actionable.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
>>> creds = resolve_credentials(env={"EVALRING_API_KEY": "sk-test"})
|
|
134
|
+
>>> creds.provider
|
|
135
|
+
'evalring'
|
|
136
|
+
"""
|
|
137
|
+
environ = os.environ if env is None else env
|
|
138
|
+
|
|
139
|
+
explicit_key = _clean(api_key)
|
|
140
|
+
explicit_url = _clean(base_url)
|
|
141
|
+
|
|
142
|
+
if explicit_key:
|
|
143
|
+
return ProviderCredentials(
|
|
144
|
+
api_key=explicit_key,
|
|
145
|
+
base_url=explicit_url or _clean(environ.get("EVALRING_BASE_URL")),
|
|
146
|
+
provider="explicit",
|
|
147
|
+
source="api_key argument",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
for provider, key_var, url_var in _CREDENTIAL_SOURCES:
|
|
151
|
+
key = _clean(environ.get(key_var))
|
|
152
|
+
if not key:
|
|
153
|
+
continue
|
|
154
|
+
resolved_url = (
|
|
155
|
+
explicit_url
|
|
156
|
+
or _clean(environ.get("EVALRING_BASE_URL"))
|
|
157
|
+
or _clean(environ.get(url_var))
|
|
158
|
+
or _PROVIDER_DEFAULT_BASE_URL.get(provider)
|
|
159
|
+
)
|
|
160
|
+
return ProviderCredentials(
|
|
161
|
+
api_key=key,
|
|
162
|
+
base_url=resolved_url,
|
|
163
|
+
provider=provider,
|
|
164
|
+
source=f"${key_var}",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return ProviderCredentials(
|
|
168
|
+
api_key=None,
|
|
169
|
+
base_url=explicit_url or _clean(environ.get("EVALRING_BASE_URL")),
|
|
170
|
+
provider="none",
|
|
171
|
+
source="unset",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def resolve_model_name(
|
|
176
|
+
model_name: str | None = None,
|
|
177
|
+
*,
|
|
178
|
+
default: str | None = None,
|
|
179
|
+
env: dict[str, str] | None = None,
|
|
180
|
+
) -> str | None:
|
|
181
|
+
"""Resolve the model identifier to evaluate.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
model_name: Explicit model identifier; returned unchanged when given.
|
|
185
|
+
default: Value returned when neither the argument nor any environment
|
|
186
|
+
variable is set.
|
|
187
|
+
env: Environment mapping to read from. Defaults to ``os.environ``.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
The resolved model identifier, or ``default`` when nothing is set.
|
|
191
|
+
"""
|
|
192
|
+
environ = os.environ if env is None else env
|
|
193
|
+
|
|
194
|
+
explicit = _clean(model_name)
|
|
195
|
+
if explicit:
|
|
196
|
+
return explicit
|
|
197
|
+
|
|
198
|
+
for var in _MODEL_VARS:
|
|
199
|
+
value = _clean(environ.get(var))
|
|
200
|
+
if value:
|
|
201
|
+
return value
|
|
202
|
+
|
|
203
|
+
return default
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def has_any_credentials(env: dict[str, str] | None = None) -> bool:
|
|
207
|
+
"""Report whether any recognized API-key variable is set.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
env: Environment mapping to read from. Defaults to ``os.environ``.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
``True`` if at least one credential variable holds a non-blank value.
|
|
214
|
+
"""
|
|
215
|
+
environ = os.environ if env is None else env
|
|
216
|
+
return any(_clean(environ.get(var)) for var in CREDENTIAL_ENV_VARS)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from tqdm.asyncio import tqdm
|
|
7
|
+
|
|
8
|
+
from ..config import resolve_credentials, resolve_model_name
|
|
9
|
+
from ..logging_utils import get_logger
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AIDataGenerator:
|
|
15
|
+
"""
|
|
16
|
+
A concurrent data generator that rewrites text instances from one class
|
|
17
|
+
label to another using an LLM, guided by a system prompt (typically the
|
|
18
|
+
class definitions).
|
|
19
|
+
|
|
20
|
+
Requires the optional ``datagen`` extra: ``pip install evalring[datagen]``.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
api_key: API key. Resolved from the environment when omitted; see
|
|
24
|
+
:func:`EvalRing.config.resolve_credentials`.
|
|
25
|
+
model_name: Model identifier. Defaults to ``$EVALRING_MODEL``, then
|
|
26
|
+
``"gpt-4o-mini"``.
|
|
27
|
+
base_url: OpenAI-compatible endpoint. Resolved alongside the API key
|
|
28
|
+
when omitted.
|
|
29
|
+
max_concurrent: Maximum number of in-flight requests.
|
|
30
|
+
system_prompt: System message prepended to every rewrite request.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str | None = None,
|
|
36
|
+
model_name: str | None = None,
|
|
37
|
+
base_url: str | None = None,
|
|
38
|
+
max_concurrent: int = 50,
|
|
39
|
+
system_prompt: str = "",
|
|
40
|
+
):
|
|
41
|
+
try:
|
|
42
|
+
from openai import AsyncOpenAI
|
|
43
|
+
except ImportError as exc: # pragma: no cover - exercised via extras
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"AIDataGenerator requires the 'openai' package. "
|
|
46
|
+
"Install it with: pip install evalring[datagen]"
|
|
47
|
+
) from exc
|
|
48
|
+
|
|
49
|
+
credentials = resolve_credentials(api_key, base_url)
|
|
50
|
+
self.credentials = credentials
|
|
51
|
+
self.api_key = credentials.require_key()
|
|
52
|
+
self.base_url = credentials.base_url
|
|
53
|
+
self.model_name = resolve_model_name(model_name, default="gpt-4o-mini") or "gpt-4o-mini"
|
|
54
|
+
self.max_concurrent = max_concurrent
|
|
55
|
+
self.system_prompt = system_prompt
|
|
56
|
+
|
|
57
|
+
self.client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
|
|
58
|
+
self.semaphore = asyncio.Semaphore(self.max_concurrent)
|
|
59
|
+
|
|
60
|
+
async def _generate_single(
|
|
61
|
+
self, text: str, original_label: str, target_label: str, retries: int = 3
|
|
62
|
+
) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Rewrites a single piece of text to fit the target_label.
|
|
65
|
+
"""
|
|
66
|
+
async with self.semaphore:
|
|
67
|
+
prompt = (
|
|
68
|
+
f"You are an expert data generator and text rewriter.\n"
|
|
69
|
+
f"I will provide you with a user's text that is currently classified as '{original_label}'.\n"
|
|
70
|
+
f"Your task is to rewrite this text from the exact same user's perspective, but modifying it so that it "
|
|
71
|
+
f"naturally, realistically, and accurately fits the definition of the '{target_label}' class.\n\n"
|
|
72
|
+
f"CRITICAL INSTRUCTIONS:\n"
|
|
73
|
+
f"1. You MUST maintain the original first-person perspective of the poster.\n"
|
|
74
|
+
f"2. You MUST NOT write a reply, supportive message, or answer to the text.\n"
|
|
75
|
+
f"3. Do not make up your own class, strictly map the text to represent '{target_label}'.\n\n"
|
|
76
|
+
f"Original Text (Label: {original_label}):\n{text}\n\n"
|
|
77
|
+
f"Please provide ONLY the rewritten text for the '{target_label}' class. "
|
|
78
|
+
f"Do not include quotes, conversational filler, or markdown blocks. "
|
|
79
|
+
f"It should read like an authentic user post."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
messages: list[Any] = []
|
|
83
|
+
if self.system_prompt:
|
|
84
|
+
messages.append({"role": "system", "content": self.system_prompt})
|
|
85
|
+
|
|
86
|
+
messages.append({"role": "user", "content": prompt})
|
|
87
|
+
|
|
88
|
+
for attempt in range(retries):
|
|
89
|
+
try:
|
|
90
|
+
response = await self.client.chat.completions.create(
|
|
91
|
+
model=self.model_name, messages=messages, temperature=0.7, max_tokens=1000
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
content = response.choices[0].message.content
|
|
95
|
+
if content:
|
|
96
|
+
return content.strip()
|
|
97
|
+
except Exception as e:
|
|
98
|
+
if attempt == retries - 1:
|
|
99
|
+
logger.warning(
|
|
100
|
+
"Error generating data for text (target=%s): %s", target_label, e
|
|
101
|
+
)
|
|
102
|
+
return ""
|
|
103
|
+
await asyncio.sleep(2**attempt)
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
async def generate_batch(
|
|
107
|
+
self, texts: list[str], current_labels: list[str], target_labels: list[str]
|
|
108
|
+
) -> list[str]:
|
|
109
|
+
"""
|
|
110
|
+
Concurrently process a batch of text rewrites.
|
|
111
|
+
"""
|
|
112
|
+
tasks = [
|
|
113
|
+
self._generate_single(text, orig, tgt)
|
|
114
|
+
for text, orig, tgt in zip(texts, current_labels, target_labels, strict=False)
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# tqdm.gather provides a nice progress bar for the async tasks
|
|
118
|
+
results = await tqdm.gather(*tasks, desc="Generating data")
|
|
119
|
+
return results
|
|
120
|
+
|
|
121
|
+
def run_dataframe(
|
|
122
|
+
self,
|
|
123
|
+
df: pd.DataFrame,
|
|
124
|
+
text_col: str,
|
|
125
|
+
label_col: str,
|
|
126
|
+
target_col: str,
|
|
127
|
+
replace_original: bool = False,
|
|
128
|
+
) -> pd.DataFrame:
|
|
129
|
+
"""
|
|
130
|
+
Synchronous wrapper to process a Pandas DataFrame.
|
|
131
|
+
"""
|
|
132
|
+
# Ensure we have an active event loop for the async execution
|
|
133
|
+
try:
|
|
134
|
+
loop = asyncio.get_running_loop()
|
|
135
|
+
except RuntimeError:
|
|
136
|
+
loop = asyncio.new_event_loop()
|
|
137
|
+
asyncio.set_event_loop(loop)
|
|
138
|
+
|
|
139
|
+
if loop.is_running():
|
|
140
|
+
import nest_asyncio
|
|
141
|
+
|
|
142
|
+
nest_asyncio.apply(loop)
|
|
143
|
+
|
|
144
|
+
texts = []
|
|
145
|
+
current_labels = []
|
|
146
|
+
target_labels = []
|
|
147
|
+
original_indices = []
|
|
148
|
+
|
|
149
|
+
is_list_of_classes = isinstance(target_col, (list, tuple, pd.Series))
|
|
150
|
+
target_col_list = list(target_col) if is_list_of_classes else None
|
|
151
|
+
|
|
152
|
+
for idx, row in df.iterrows():
|
|
153
|
+
text = row[text_col]
|
|
154
|
+
orig = row[label_col]
|
|
155
|
+
|
|
156
|
+
if is_list_of_classes:
|
|
157
|
+
# generate for all target classes except the original
|
|
158
|
+
targets = [t for t in (target_col_list or []) if t != orig]
|
|
159
|
+
else:
|
|
160
|
+
# generate specifically for the target_col specified in the row
|
|
161
|
+
targets = [row[target_col]]
|
|
162
|
+
|
|
163
|
+
for tgt in targets:
|
|
164
|
+
texts.append(text)
|
|
165
|
+
current_labels.append(orig)
|
|
166
|
+
target_labels.append(tgt)
|
|
167
|
+
original_indices.append(idx)
|
|
168
|
+
|
|
169
|
+
results = loop.run_until_complete(self.generate_batch(texts, current_labels, target_labels))
|
|
170
|
+
|
|
171
|
+
# Positional selection with a list of row numbers yields a DataFrame, but
|
|
172
|
+
# the pandas stubs describe iloc's return as a union that collapses to
|
|
173
|
+
# Series once numpy's own stubs are not in play.
|
|
174
|
+
df_out = cast(pd.DataFrame, df.iloc[list(original_indices)]).copy()
|
|
175
|
+
|
|
176
|
+
if replace_original:
|
|
177
|
+
df_out[text_col] = results
|
|
178
|
+
df_out[label_col] = target_labels
|
|
179
|
+
if not is_list_of_classes and isinstance(target_col, str):
|
|
180
|
+
if target_col in df_out.columns:
|
|
181
|
+
df_out.drop(columns=[target_col], inplace=True)
|
|
182
|
+
else:
|
|
183
|
+
df_out["rewritten_text"] = results
|
|
184
|
+
df_out["target_label"] = target_labels
|
|
185
|
+
|
|
186
|
+
return df_out
|
|
187
|
+
|
|
188
|
+
def process_csv(
|
|
189
|
+
self,
|
|
190
|
+
input_csv_path: str,
|
|
191
|
+
text_col: str,
|
|
192
|
+
label_col: str,
|
|
193
|
+
target_col: str,
|
|
194
|
+
output_csv_path: str | None = None,
|
|
195
|
+
) -> str:
|
|
196
|
+
"""
|
|
197
|
+
Reads a CSV, generates new texts based on the target class, replaces the original
|
|
198
|
+
text and label fields, and saves to {original}_generated.csv.
|
|
199
|
+
"""
|
|
200
|
+
if not output_csv_path:
|
|
201
|
+
base, ext = os.path.splitext(input_csv_path)
|
|
202
|
+
output_csv_path = f"{base}_generated{ext}"
|
|
203
|
+
|
|
204
|
+
df = pd.read_csv(input_csv_path)
|
|
205
|
+
|
|
206
|
+
logger.info("Loaded %d rows from %s", len(df), input_csv_path)
|
|
207
|
+
df_generated = self.run_dataframe(
|
|
208
|
+
df=df,
|
|
209
|
+
text_col=text_col,
|
|
210
|
+
label_col=label_col,
|
|
211
|
+
target_col=target_col,
|
|
212
|
+
replace_original=True,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
df_generated.to_csv(output_csv_path, index=False)
|
|
216
|
+
logger.info("Saved generated dataset to %s", output_csv_path)
|
|
217
|
+
return output_csv_path
|
EvalRing/dataset/base.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base classes for datasets in the EvalRing framework.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DataSample:
|
|
17
|
+
"""Represents a single data sample for evaluation."""
|
|
18
|
+
|
|
19
|
+
id: str
|
|
20
|
+
input_text: str
|
|
21
|
+
target_output: Any
|
|
22
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, Any]:
|
|
25
|
+
"""Convert to dictionary representation."""
|
|
26
|
+
return {
|
|
27
|
+
"id": self.id,
|
|
28
|
+
"input_text": self.input_text,
|
|
29
|
+
"target_output": self.target_output,
|
|
30
|
+
"metadata": self.metadata,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BaseDataset(ABC):
|
|
35
|
+
"""
|
|
36
|
+
Abstract base class for all datasets in EvalRing.
|
|
37
|
+
|
|
38
|
+
This class provides a standardized interface for loading and accessing
|
|
39
|
+
different types of datasets (JSON, CSV, DataFrame, etc.).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, name: str, description: str | None = None, version: str = "1.0"):
|
|
43
|
+
self.name = name
|
|
44
|
+
self.description = description or f"Dataset: {name}"
|
|
45
|
+
self.version = version
|
|
46
|
+
self._samples: list[DataSample] = []
|
|
47
|
+
self._metadata: dict[str, Any] = {}
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def load_data(
|
|
51
|
+
self,
|
|
52
|
+
source: str | Path | pd.DataFrame,
|
|
53
|
+
text_field: str = "text",
|
|
54
|
+
label_field: str = "label",
|
|
55
|
+
id_field: str | None = None,
|
|
56
|
+
**kwargs: Any,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Load samples from the given source.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
source: File path or in-memory frame, depending on the subclass.
|
|
62
|
+
text_field: Column or key holding the model input.
|
|
63
|
+
label_field: Column or key holding the ground-truth label.
|
|
64
|
+
id_field: Column or key holding a unique per-sample ID. When
|
|
65
|
+
``None`` the row index is used, which is only safe for datasets
|
|
66
|
+
that are never resumed or retried.
|
|
67
|
+
**kwargs: Reader-specific options (for example ``pandas.read_csv``
|
|
68
|
+
keyword arguments).
|
|
69
|
+
"""
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def validate_data(self) -> bool:
|
|
74
|
+
"""
|
|
75
|
+
Validate the loaded data for consistency and correctness.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if data is valid, False otherwise
|
|
79
|
+
"""
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
def assert_unique_ids(
|
|
83
|
+
self, *, expected_count: int | None = None, context: str = "dataset"
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Raise if sample IDs are not unique.
|
|
86
|
+
|
|
87
|
+
Unique per-sample IDs are required for:
|
|
88
|
+
- resuming partial runs
|
|
89
|
+
- retrying failures
|
|
90
|
+
- merging per-sample metrics
|
|
91
|
+
"""
|
|
92
|
+
ids = [str(s.id) for s in self._samples]
|
|
93
|
+
unique_ids = set(ids)
|
|
94
|
+
|
|
95
|
+
if expected_count is None:
|
|
96
|
+
expected_count = len(ids)
|
|
97
|
+
|
|
98
|
+
if len(ids) != expected_count:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"[{context}] Expected {expected_count} samples, but found {len(ids)}."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if len(unique_ids) != len(ids):
|
|
104
|
+
# Provide a small, actionable duplicate preview.
|
|
105
|
+
from collections import Counter
|
|
106
|
+
|
|
107
|
+
counts = Counter(ids)
|
|
108
|
+
dupes = [k for k, v in counts.items() if v > 1]
|
|
109
|
+
preview = dupes[:10]
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"[{context}] Duplicate sample IDs detected: unique={len(unique_ids)} total={len(ids)} "
|
|
112
|
+
f"(first {len(preview)} duplicates: {preview}). "
|
|
113
|
+
"Fix by using a per-row question/message ID (e.g., an explicit 'ID' column or row index), not a user ID."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def add_sample(self, sample: DataSample) -> None:
|
|
117
|
+
"""Add a single sample to the dataset."""
|
|
118
|
+
self._samples.append(sample)
|
|
119
|
+
|
|
120
|
+
def get_sample(self, index: int) -> DataSample:
|
|
121
|
+
"""Get a sample by index."""
|
|
122
|
+
return self._samples[index]
|
|
123
|
+
|
|
124
|
+
def get_sample_by_id(self, sample_id: str) -> DataSample | None:
|
|
125
|
+
"""Get a sample by its ID."""
|
|
126
|
+
for sample in self._samples:
|
|
127
|
+
if sample.id == sample_id:
|
|
128
|
+
return sample
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
def __len__(self) -> int:
|
|
132
|
+
"""Return the number of samples in the dataset."""
|
|
133
|
+
return len(self._samples)
|
|
134
|
+
|
|
135
|
+
def __iter__(self) -> Iterator[DataSample]:
|
|
136
|
+
"""Iterate over samples in the dataset."""
|
|
137
|
+
return iter(self._samples)
|
|
138
|
+
|
|
139
|
+
def __getitem__(self, index: int | slice) -> DataSample | list[DataSample]:
|
|
140
|
+
"""Get sample(s) by index or slice."""
|
|
141
|
+
return self._samples[index]
|
|
142
|
+
|
|
143
|
+
def get_statistics(self) -> dict[str, Any]:
|
|
144
|
+
"""Get dataset statistics."""
|
|
145
|
+
if not self._samples:
|
|
146
|
+
return {"total_samples": 0}
|
|
147
|
+
|
|
148
|
+
# Basic statistics
|
|
149
|
+
stats = {
|
|
150
|
+
"total_samples": len(self._samples),
|
|
151
|
+
"name": self.name,
|
|
152
|
+
"version": self.version,
|
|
153
|
+
"description": self.description,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Try to infer additional statistics
|
|
157
|
+
try:
|
|
158
|
+
input_lengths = [len(sample.input_text) for sample in self._samples]
|
|
159
|
+
stats.update(
|
|
160
|
+
{
|
|
161
|
+
"avg_input_length": sum(input_lengths) / len(input_lengths),
|
|
162
|
+
"min_input_length": min(input_lengths),
|
|
163
|
+
"max_input_length": max(input_lengths),
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
return stats
|
|
170
|
+
|
|
171
|
+
def split(
|
|
172
|
+
self, train_ratio: float = 0.8, seed: int | None = None
|
|
173
|
+
) -> tuple["BaseDataset", "BaseDataset"]:
|
|
174
|
+
"""
|
|
175
|
+
Split the dataset into train and test sets.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
train_ratio: Ratio of data to use for training
|
|
179
|
+
seed: Random seed for reproducible splits
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Tuple of (train_dataset, test_dataset)
|
|
183
|
+
"""
|
|
184
|
+
import random
|
|
185
|
+
|
|
186
|
+
if seed is not None:
|
|
187
|
+
random.seed(seed)
|
|
188
|
+
|
|
189
|
+
indices = list(range(len(self._samples)))
|
|
190
|
+
random.shuffle(indices)
|
|
191
|
+
|
|
192
|
+
split_idx = int(len(indices) * train_ratio)
|
|
193
|
+
train_indices = indices[:split_idx]
|
|
194
|
+
test_indices = indices[split_idx:]
|
|
195
|
+
|
|
196
|
+
train_dataset = self.__class__(name=f"{self.name}_train", version=self.version)
|
|
197
|
+
test_dataset = self.__class__(name=f"{self.name}_test", version=self.version)
|
|
198
|
+
|
|
199
|
+
for idx in train_indices:
|
|
200
|
+
train_dataset.add_sample(self._samples[idx])
|
|
201
|
+
|
|
202
|
+
for idx in test_indices:
|
|
203
|
+
test_dataset.add_sample(self._samples[idx])
|
|
204
|
+
|
|
205
|
+
return train_dataset, test_dataset
|
|
206
|
+
|
|
207
|
+
def save(self, filepath: str | Path) -> None:
|
|
208
|
+
"""Save dataset to file."""
|
|
209
|
+
data = {
|
|
210
|
+
"name": self.name,
|
|
211
|
+
"description": self.description,
|
|
212
|
+
"version": self.version,
|
|
213
|
+
"metadata": self._metadata,
|
|
214
|
+
"samples": [sample.to_dict() for sample in self._samples],
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
with open(filepath, "w") as f:
|
|
218
|
+
json.dump(data, f, indent=2)
|
|
219
|
+
|
|
220
|
+
def load_from_file(self, filepath: str | Path) -> None:
|
|
221
|
+
"""Load dataset from file."""
|
|
222
|
+
with open(filepath) as f:
|
|
223
|
+
data = json.load(f)
|
|
224
|
+
|
|
225
|
+
self.name = data.get("name", self.name)
|
|
226
|
+
self.description = data.get("description", self.description)
|
|
227
|
+
self.version = data.get("version", self.version)
|
|
228
|
+
self._metadata = data.get("metadata", {})
|
|
229
|
+
|
|
230
|
+
self._samples = []
|
|
231
|
+
for sample_data in data.get("samples", []):
|
|
232
|
+
sample = DataSample(
|
|
233
|
+
id=sample_data["id"],
|
|
234
|
+
input_text=sample_data["input_text"],
|
|
235
|
+
target_output=sample_data["target_output"],
|
|
236
|
+
metadata=sample_data.get("metadata", {}),
|
|
237
|
+
)
|
|
238
|
+
self._samples.append(sample)
|