dineshb commited on
Commit
f8e45ae
·
verified ·
1 Parent(s): 489825c

Add stable hosted evidence brief

Browse files

Provide a deterministic evidence-only analyst narrative when hosted platforms restrict reliable external provider traffic.

Files changed (1) hide show
  1. datapilot/analyst.py +295 -266
datapilot/analyst.py CHANGED
@@ -1,268 +1,297 @@
1
- from __future__ import annotations
2
-
3
- import hashlib
4
- import io
5
- import json
6
- import logging
7
- import re
8
- from dataclasses import dataclass
9
- from typing import Any
10
- from urllib.parse import quote
11
-
12
- import numpy as np
13
- import pandas as pd
14
- import requests
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
- TARGET_WORDS = {
19
- "target": 1.0,
20
- "label": 1.0,
21
- "outcome": 0.95,
22
- "class": 0.9,
23
- "churn": 0.95,
24
- "fraud": 0.95,
25
- "default": 0.9,
26
- "price": 0.8,
27
- "revenue": 0.75,
28
- "sales": 0.75,
29
- "diagnosis": 0.9,
30
- "status": 0.7,
31
- "response": 0.8,
32
- "converted": 0.9,
33
- }
34
- PII_PATTERN = re.compile(r"(email|phone|mobile|address|ssn|passport|account|name)", re.I)
35
-
36
-
37
- @dataclass
38
- class DatasetBrief:
39
- fingerprint: str
40
- rows: int
41
- columns: int
42
- numeric: int
43
- categorical: int
44
- datetime: int
45
- missing_cells: int
46
- duplicate_rows: int
47
- memory_mb: float
48
-
49
-
50
- def inspect_dataset(frame: pd.DataFrame) -> dict[str, Any]:
51
- """Compute an immediate analyst profile without requiring a target."""
52
- numeric = list(frame.select_dtypes(include=np.number).columns)
53
- datetime = list(frame.select_dtypes(include=["datetime", "datetimetz"]).columns)
54
- categorical = [c for c in frame.columns if c not in numeric and c not in datetime]
55
- missing = frame.isna().sum()
56
- brief = DatasetBrief(
57
- fingerprint=hashlib.sha256(
58
- pd.util.hash_pandas_object(frame, index=True).values.tobytes()
59
- ).hexdigest()[:12],
60
- rows=len(frame),
61
- columns=len(frame.columns),
62
- numeric=len(numeric),
63
- categorical=len(categorical),
64
- datetime=len(datetime),
65
- missing_cells=int(missing.sum()),
66
- duplicate_rows=int(frame.duplicated().sum()),
67
- memory_mb=round(float(frame.memory_usage(deep=True).sum() / 1_048_576), 2),
68
- )
69
- dictionary = build_data_dictionary(frame)
70
- quality_score = max(
71
- 0,
72
- round(
73
- 100
74
- - (brief.missing_cells / max(1, frame.size)) * 45
75
- - (brief.duplicate_rows / max(1, brief.rows)) * 25
76
- - sum(dictionary["issue_count"].clip(upper=3)) / max(1, len(dictionary)) * 4
77
- ),
78
- )
79
- return {
80
- "brief": brief,
81
- "dictionary": dictionary,
82
- "targets": rank_target_candidates(frame),
83
- "quality_score": quality_score,
84
- "missing": missing.sort_values(ascending=False),
85
- "numeric": numeric,
86
- "categorical": categorical,
87
- "datetime": datetime,
88
- "correlation": frame[numeric].corr(numeric_only=True)
89
- if len(numeric) > 1
90
- else pd.DataFrame(),
91
- }
92
-
93
-
94
- def build_data_dictionary(frame: pd.DataFrame) -> pd.DataFrame:
95
- """Create an evidence-based data dictionary for every column."""
96
- rows: list[dict[str, Any]] = []
97
- for column in frame.columns:
98
- series = frame[column]
99
- unique, missing = int(series.nunique(dropna=True)), int(series.isna().sum())
100
- issues: list[str] = []
101
- if missing:
102
- issues.append("Missing values")
103
- if unique <= 1:
104
- issues.append("Constant")
105
- if len(frame) and unique / len(frame) > 0.98:
106
- issues.append("Identifier-like")
107
- if PII_PATTERN.search(str(column)):
108
- issues.append("Potential PII")
109
- role = "Numeric feature" if pd.api.types.is_numeric_dtype(series) else "Categorical feature"
110
- if pd.api.types.is_datetime64_any_dtype(series):
111
- role = "Datetime"
112
- elif unique == len(frame) and len(frame) > 10:
113
- role = "Identifier"
114
- rows.append(
115
- {
116
- "column": str(column),
117
- "type": str(series.dtype),
118
- "role": role,
119
- "unique": unique,
120
- "missing": missing,
121
- "missing_%": round(missing / max(1, len(frame)) * 100, 2),
122
- "example_values": ", ".join(map(str, series.dropna().astype(str).unique()[:3]))[
123
- :90
124
- ],
125
- "issues": ", ".join(issues) or "None detected",
126
- "issue_count": len(issues),
127
- }
128
- )
129
- return pd.DataFrame(rows)
130
-
131
-
132
- def rank_target_candidates(frame: pd.DataFrame) -> list[dict[str, Any]]:
133
- """Rank targets while explicitly leaving confirmation to the user."""
134
- ranked: list[dict[str, Any]] = []
135
- for position, column in enumerate(frame.columns):
136
- series, name = frame[column], str(column).lower().strip()
137
- cardinality = int(series.nunique(dropna=True))
138
- score = max((v for word, v in TARGET_WORDS.items() if word in name), default=0.0)
139
- if 2 <= cardinality <= max(20, int(len(frame) * 0.1)):
140
- score += 0.22
141
- if position == len(frame.columns) - 1:
142
- score += 0.12
143
- if cardinality >= max(10, int(len(frame) * 0.95)):
144
- score -= 0.45
145
- if series.isna().mean() > 0.5 or cardinality < 2:
146
- score -= 0.5
147
- task = "classification" if cardinality <= max(20, int(len(frame) * 0.05)) else "regression"
148
- if pd.api.types.is_numeric_dtype(series) and cardinality > 20:
149
- task = "regression"
150
- ranked.append(
151
- {
152
- "column": str(column),
153
- "task": task,
154
- "confidence": round(max(0, min(score, 0.99)), 2),
155
- "reason": f"{cardinality:,} distinct values; "
156
- + ("name/position signals detected" if score > 0.3 else "weak heuristic evidence"),
157
- }
158
- )
159
- return sorted(ranked, key=lambda item: item["confidence"], reverse=True)[:5]
160
-
161
-
162
- def ai_context(frame: pd.DataFrame, profile: dict[str, Any], excluded: list[str]) -> dict[str, Any]:
163
- """Build a bounded, redacted payload suitable for AI interpretation."""
164
- safe = frame.drop(columns=[c for c in excluded if c in frame], errors="ignore").copy()
165
- pii = [c for c in safe.columns if PII_PATTERN.search(str(c))]
166
- safe = safe.drop(columns=pii, errors="ignore")
167
- return {
168
- "shape": list(frame.shape),
169
- "columns": profile["dictionary"].drop(columns=["issue_count"]).to_dict(orient="records"),
170
- "numeric_summary": safe.select_dtypes(include=np.number).describe().round(3).to_dict(),
171
- "sample": safe.head(3).replace({np.nan: None}).to_dict(orient="records"),
172
- "excluded_columns": sorted(set(excluded + pii)),
173
- "quality_score": profile["quality_score"],
174
- "target_candidates": profile["targets"],
175
- }
176
-
177
-
178
- def gemini_dataset_summary(
179
- frame: pd.DataFrame, profile: dict[str, Any], api_key: str, model: str, excluded: list[str]
180
- ) -> str:
181
- """Ask Gemini for an evidence-bounded narrative through a bounded REST call."""
182
- if not api_key:
183
- raise ValueError("Enter a Gemini API key to generate AI interpretation.")
184
-
185
- allowed_models = {"gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"}
186
- if model not in allowed_models:
187
- raise ValueError("The selected Gemini model is not supported.")
188
-
189
- evidence = json.dumps(ai_context(frame, profile, excluded), default=str)
190
- if len(evidence) > 30_000:
191
- raise ValueError(
192
- "The AI evidence package is too large. Exclude columns or use a smaller dataset."
193
- )
194
-
195
- prompt = (
196
- """You are DataPilot, a rigorous senior data analyst. Use ONLY the supplied JSON.
197
- Return concise Markdown with exactly these headings: Finding, Evidence, Interpretation,
198
- Limitation, Recommendation. Explain likely row grain and useful business questions, but label
199
- uncertain semantics as assumptions. Never invent values, origin, or causal claims.
200
- DATA:\n"""
201
- + evidence
202
- )
203
-
204
- endpoint = (
205
- "https://generativelanguage.googleapis.com/v1beta/models/"
206
- f"{quote(model, safe='')}:generateContent"
207
- )
208
- payload = {
209
- "contents": [{"role": "user", "parts": [{"text": prompt}]}],
210
- "generationConfig": {"maxOutputTokens": 1200},
211
- }
212
-
213
- try:
214
- logger.warning("gemini_request_started model=%s evidence_chars=%d", model, len(evidence))
215
- response = requests.post(
216
- endpoint,
217
- headers={
218
- "x-goog-api-key": api_key,
219
- "Content-Type": "application/json",
220
- "X-Server-Timeout": "30",
221
- },
222
- json=payload,
223
  timeout=(3, 8),
224
- )
225
- logger.warning("gemini_response_received status=%d", response.status_code)
226
- except requests.Timeout as exc:
227
- logger.warning("gemini_request_timed_out")
228
  raise ValueError("Gemini timed out after 8 seconds. Please try again.") from exc
229
- except requests.ConnectionError as exc:
230
- logger.warning("gemini_connection_failed")
231
- raise ValueError("DataPilot could not connect to Gemini. Please try again.") from exc
232
- except requests.RequestException as exc:
233
- logger.warning("gemini_request_failed category=%s", type(exc).__name__)
234
- raise ValueError("The Gemini request could not be completed.") from exc
235
-
236
- status_messages = {
237
- 400: "Gemini rejected the evidence request.",
238
- 401: "The Gemini API key was rejected.",
239
- 403: "Gemini access is not enabled for this API key or project.",
240
- 404: "The selected Gemini model is unavailable. Select Gemini Flash.",
241
- 429: "Gemini quota is temporarily exhausted. Please try again later.",
242
- }
243
- if response.status_code in status_messages:
244
- raise ValueError(status_messages[response.status_code])
245
- if response.status_code >= 500:
246
- raise ValueError("Gemini is temporarily unavailable. Please try again later.")
247
- if not response.ok:
248
- raise ValueError(f"Gemini returned an unexpected response ({response.status_code}).")
249
-
250
- try:
251
- body = response.json()
252
- parts = body["candidates"][0]["content"]["parts"]
253
- text = "\n".join(
254
- part["text"].strip() for part in parts if isinstance(part, dict) and part.get("text")
255
- )
256
- except (ValueError, KeyError, IndexError, TypeError) as exc:
257
- raise ValueError("Gemini returned an invalid or empty response.") from exc
258
-
259
- if not text:
260
- raise ValueError("Gemini returned no text. The request may have been blocked.")
261
- logger.warning("gemini_response_validated output_chars=%d", len(text))
262
- return text
263
-
264
-
265
- def dataframe_csv(frame: pd.DataFrame) -> bytes:
266
- buffer = io.StringIO()
267
- frame.to_csv(buffer, index=False)
268
- return buffer.getvalue().encode("utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import logging
7
+ import re
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import requests
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ TARGET_WORDS = {
19
+ "target": 1.0,
20
+ "label": 1.0,
21
+ "outcome": 0.95,
22
+ "class": 0.9,
23
+ "churn": 0.95,
24
+ "fraud": 0.95,
25
+ "default": 0.9,
26
+ "price": 0.8,
27
+ "revenue": 0.75,
28
+ "sales": 0.75,
29
+ "diagnosis": 0.9,
30
+ "status": 0.7,
31
+ "response": 0.8,
32
+ "converted": 0.9,
33
+ }
34
+ PII_PATTERN = re.compile(r"(email|phone|mobile|address|ssn|passport|account|name)", re.I)
35
+
36
+
37
+ @dataclass
38
+ class DatasetBrief:
39
+ fingerprint: str
40
+ rows: int
41
+ columns: int
42
+ numeric: int
43
+ categorical: int
44
+ datetime: int
45
+ missing_cells: int
46
+ duplicate_rows: int
47
+ memory_mb: float
48
+
49
+
50
+ def inspect_dataset(frame: pd.DataFrame) -> dict[str, Any]:
51
+ """Compute an immediate analyst profile without requiring a target."""
52
+ numeric = list(frame.select_dtypes(include=np.number).columns)
53
+ datetime = list(frame.select_dtypes(include=["datetime", "datetimetz"]).columns)
54
+ categorical = [c for c in frame.columns if c not in numeric and c not in datetime]
55
+ missing = frame.isna().sum()
56
+ brief = DatasetBrief(
57
+ fingerprint=hashlib.sha256(
58
+ pd.util.hash_pandas_object(frame, index=True).values.tobytes()
59
+ ).hexdigest()[:12],
60
+ rows=len(frame),
61
+ columns=len(frame.columns),
62
+ numeric=len(numeric),
63
+ categorical=len(categorical),
64
+ datetime=len(datetime),
65
+ missing_cells=int(missing.sum()),
66
+ duplicate_rows=int(frame.duplicated().sum()),
67
+ memory_mb=round(float(frame.memory_usage(deep=True).sum() / 1_048_576), 2),
68
+ )
69
+ dictionary = build_data_dictionary(frame)
70
+ quality_score = max(
71
+ 0,
72
+ round(
73
+ 100
74
+ - (brief.missing_cells / max(1, frame.size)) * 45
75
+ - (brief.duplicate_rows / max(1, brief.rows)) * 25
76
+ - sum(dictionary["issue_count"].clip(upper=3)) / max(1, len(dictionary)) * 4
77
+ ),
78
+ )
79
+ return {
80
+ "brief": brief,
81
+ "dictionary": dictionary,
82
+ "targets": rank_target_candidates(frame),
83
+ "quality_score": quality_score,
84
+ "missing": missing.sort_values(ascending=False),
85
+ "numeric": numeric,
86
+ "categorical": categorical,
87
+ "datetime": datetime,
88
+ "correlation": frame[numeric].corr(numeric_only=True)
89
+ if len(numeric) > 1
90
+ else pd.DataFrame(),
91
+ }
92
+
93
+
94
+ def build_data_dictionary(frame: pd.DataFrame) -> pd.DataFrame:
95
+ """Create an evidence-based data dictionary for every column."""
96
+ rows: list[dict[str, Any]] = []
97
+ for column in frame.columns:
98
+ series = frame[column]
99
+ unique, missing = int(series.nunique(dropna=True)), int(series.isna().sum())
100
+ issues: list[str] = []
101
+ if missing:
102
+ issues.append("Missing values")
103
+ if unique <= 1:
104
+ issues.append("Constant")
105
+ if len(frame) and unique / len(frame) > 0.98:
106
+ issues.append("Identifier-like")
107
+ if PII_PATTERN.search(str(column)):
108
+ issues.append("Potential PII")
109
+ role = "Numeric feature" if pd.api.types.is_numeric_dtype(series) else "Categorical feature"
110
+ if pd.api.types.is_datetime64_any_dtype(series):
111
+ role = "Datetime"
112
+ elif unique == len(frame) and len(frame) > 10:
113
+ role = "Identifier"
114
+ rows.append(
115
+ {
116
+ "column": str(column),
117
+ "type": str(series.dtype),
118
+ "role": role,
119
+ "unique": unique,
120
+ "missing": missing,
121
+ "missing_%": round(missing / max(1, len(frame)) * 100, 2),
122
+ "example_values": ", ".join(map(str, series.dropna().astype(str).unique()[:3]))[
123
+ :90
124
+ ],
125
+ "issues": ", ".join(issues) or "None detected",
126
+ "issue_count": len(issues),
127
+ }
128
+ )
129
+ return pd.DataFrame(rows)
130
+
131
+
132
+ def rank_target_candidates(frame: pd.DataFrame) -> list[dict[str, Any]]:
133
+ """Rank targets while explicitly leaving confirmation to the user."""
134
+ ranked: list[dict[str, Any]] = []
135
+ for position, column in enumerate(frame.columns):
136
+ series, name = frame[column], str(column).lower().strip()
137
+ cardinality = int(series.nunique(dropna=True))
138
+ score = max((v for word, v in TARGET_WORDS.items() if word in name), default=0.0)
139
+ if 2 <= cardinality <= max(20, int(len(frame) * 0.1)):
140
+ score += 0.22
141
+ if position == len(frame.columns) - 1:
142
+ score += 0.12
143
+ if cardinality >= max(10, int(len(frame) * 0.95)):
144
+ score -= 0.45
145
+ if series.isna().mean() > 0.5 or cardinality < 2:
146
+ score -= 0.5
147
+ task = "classification" if cardinality <= max(20, int(len(frame) * 0.05)) else "regression"
148
+ if pd.api.types.is_numeric_dtype(series) and cardinality > 20:
149
+ task = "regression"
150
+ ranked.append(
151
+ {
152
+ "column": str(column),
153
+ "task": task,
154
+ "confidence": round(max(0, min(score, 0.99)), 2),
155
+ "reason": f"{cardinality:,} distinct values; "
156
+ + ("name/position signals detected" if score > 0.3 else "weak heuristic evidence"),
157
+ }
158
+ )
159
+ return sorted(ranked, key=lambda item: item["confidence"], reverse=True)[:5]
160
+
161
+
162
+ def ai_context(frame: pd.DataFrame, profile: dict[str, Any], excluded: list[str]) -> dict[str, Any]:
163
+ """Build a bounded, redacted payload suitable for AI interpretation."""
164
+ safe = frame.drop(columns=[c for c in excluded if c in frame], errors="ignore").copy()
165
+ pii = [c for c in safe.columns if PII_PATTERN.search(str(c))]
166
+ safe = safe.drop(columns=pii, errors="ignore")
167
+ return {
168
+ "shape": list(frame.shape),
169
+ "columns": profile["dictionary"].drop(columns=["issue_count"]).to_dict(orient="records"),
170
+ "numeric_summary": safe.select_dtypes(include=np.number).describe().round(3).to_dict(),
171
+ "sample": safe.head(3).replace({np.nan: None}).to_dict(orient="records"),
172
+ "excluded_columns": sorted(set(excluded + pii)),
173
+ "quality_score": profile["quality_score"],
174
+ "target_candidates": profile["targets"],
175
+ }
176
+
177
+
178
+ def gemini_dataset_summary(
179
+ frame: pd.DataFrame, profile: dict[str, Any], api_key: str, model: str, excluded: list[str]
180
+ ) -> str:
181
+ """Ask Gemini for an evidence-bounded narrative through a bounded REST call."""
182
+ if not api_key:
183
+ raise ValueError("Enter a Gemini API key to generate AI interpretation.")
184
+
185
+ allowed_models = {"gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"}
186
+ if model not in allowed_models:
187
+ raise ValueError("The selected Gemini model is not supported.")
188
+
189
+ evidence = json.dumps(ai_context(frame, profile, excluded), default=str)
190
+ if len(evidence) > 30_000:
191
+ raise ValueError(
192
+ "The AI evidence package is too large. Exclude columns or use a smaller dataset."
193
+ )
194
+
195
+ prompt = (
196
+ """You are DataPilot, a rigorous senior data analyst. Use ONLY the supplied JSON.
197
+ Return concise Markdown with exactly these headings: Finding, Evidence, Interpretation,
198
+ Limitation, Recommendation. Explain likely row grain and useful business questions, but label
199
+ uncertain semantics as assumptions. Never invent values, origin, or causal claims.
200
+ DATA:\n"""
201
+ + evidence
202
+ )
203
+
204
+ endpoint = (
205
+ "https://generativelanguage.googleapis.com/v1beta/models/"
206
+ f"{quote(model, safe='')}:generateContent"
207
+ )
208
+ payload = {
209
+ "contents": [{"role": "user", "parts": [{"text": prompt}]}],
210
+ "generationConfig": {"maxOutputTokens": 1200},
211
+ }
212
+
213
+ try:
214
+ logger.warning("gemini_request_started model=%s evidence_chars=%d", model, len(evidence))
215
+ response = requests.post(
216
+ endpoint,
217
+ headers={
218
+ "x-goog-api-key": api_key,
219
+ "Content-Type": "application/json",
220
+ "X-Server-Timeout": "30",
221
+ },
222
+ json=payload,
223
  timeout=(3, 8),
224
+ )
225
+ logger.warning("gemini_response_received status=%d", response.status_code)
226
+ except requests.Timeout as exc:
227
+ logger.warning("gemini_request_timed_out")
228
  raise ValueError("Gemini timed out after 8 seconds. Please try again.") from exc
229
+ except requests.ConnectionError as exc:
230
+ logger.warning("gemini_connection_failed")
231
+ raise ValueError("DataPilot could not connect to Gemini. Please try again.") from exc
232
+ except requests.RequestException as exc:
233
+ logger.warning("gemini_request_failed category=%s", type(exc).__name__)
234
+ raise ValueError("The Gemini request could not be completed.") from exc
235
+
236
+ status_messages = {
237
+ 400: "Gemini rejected the evidence request.",
238
+ 401: "The Gemini API key was rejected.",
239
+ 403: "Gemini access is not enabled for this API key or project.",
240
+ 404: "The selected Gemini model is unavailable. Select Gemini Flash.",
241
+ 429: "Gemini quota is temporarily exhausted. Please try again later.",
242
+ }
243
+ if response.status_code in status_messages:
244
+ raise ValueError(status_messages[response.status_code])
245
+ if response.status_code >= 500:
246
+ raise ValueError("Gemini is temporarily unavailable. Please try again later.")
247
+ if not response.ok:
248
+ raise ValueError(f"Gemini returned an unexpected response ({response.status_code}).")
249
+
250
+ try:
251
+ body = response.json()
252
+ parts = body["candidates"][0]["content"]["parts"]
253
+ text = "\n".join(
254
+ part["text"].strip() for part in parts if isinstance(part, dict) and part.get("text")
255
+ )
256
+ except (ValueError, KeyError, IndexError, TypeError) as exc:
257
+ raise ValueError("Gemini returned an invalid or empty response.") from exc
258
+
259
+ if not text:
260
+ raise ValueError("Gemini returned no text. The request may have been blocked.")
261
+ logger.warning("gemini_response_validated output_chars=%d", len(text))
262
+ return text
263
+
264
+
265
+ def evidence_dataset_summary(frame: pd.DataFrame, profile: dict[str, Any]) -> str:
266
+ """Create a deterministic, evidence-only brief for restricted hosted runtimes."""
267
+ rows, columns = frame.shape
268
+ missing = int(frame.isna().sum().sum())
269
+ duplicates = int(frame.duplicated().sum())
270
+ numeric = len(frame.select_dtypes(include=np.number).columns)
271
+ target_names = [str(item.get("column")) for item in profile.get("targets", [])[:3]]
272
+ targets = ", ".join(target_names) if target_names else "No strong target candidate detected"
273
+ return (
274
+ "### Finding\n"
275
+ f"The dataset contains **{rows:,} rows and {columns:,} columns**, including "
276
+ f"**{numeric:,} numeric features**. Its computed quality score is "
277
+ f"**{profile.get('quality_score', 'not available')}/100**.\n\n"
278
+ "### Evidence\n"
279
+ f"The deterministic profile found **{missing:,} missing cells** and "
280
+ f"**{duplicates:,} duplicate rows**. Leading analytical target candidates: {targets}.\n\n"
281
+ "### Interpretation\n"
282
+ "The dataset is suitable for exploratory analysis when its row grain and field "
283
+ "definitions are confirmed. Target candidates are structural recommendations, not "
284
+ "proof of business relevance.\n\n"
285
+ "### Limitation\n"
286
+ "This hosted brief is generated from computed evidence without an external LLM. "
287
+ "Associations and model scores must not be interpreted as causal effects.\n\n"
288
+ "### Recommendation\n"
289
+ "Confirm the business objective and target meaning, review quality findings, then "
290
+ "use Model Lab with an untouched test set for final evaluation."
291
+ )
292
+
293
+
294
+ def dataframe_csv(frame: pd.DataFrame) -> bytes:
295
+ buffer = io.StringIO()
296
+ frame.to_csv(buffer, index=False)
297
+ return buffer.getvalue().encode("utf-8")