diff --git a/CHANGELOG.md b/CHANGELOG.md index 732a9fd5..e9b1ada2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **PRs need a CHANGELOG entry**—A new check fails a pull request that doesn't change `CHANGELOG.md`. Changes nobody would notice, such as a typo fix or a refactor with no import or behavior change, take the `no-changelog` label instead. `/create-pr` now drafts the entry, or adds the label, before opening the PR. - **Guards against changed scores**—Unit tests pin results with fixed inputs, so a code change can't move scores unnoticed. One per stage: golden persona prompts and a replay of recorded user and chatbot replies (generate), scripted answers walking the SI rubric (judge), and golden `scores.json` files (score); plus a replay of the whole recorded pipeline. None calls a model. See [docs/safe-changes.md](docs/safe-changes.md). +### Fixed + +- **HFO runs get a risk-level breakdown instead of a traceback**—Scoring grouped conversations by SI's `Short Current Suicide Risk Level` persona column for every target, so on HFO, whose personas file doesn't have it, `vera pipeline`, `vera score`, and `vera pool` printed a `KeyError` traceback and skipped `scores_by_risk.json`. Each target's `manifest.json` now says how to break its scores down, in an optional `risk_levels` section with the persona column and its levels in order ([docs/targets.md](docs/targets.md)): SI groups by `Short Current Suicide Risk Level` (`None`, `Low`, `High`, `Immediate`), HFO by `Active Life Threat` (`N/A: No harm`, `Absent`, `Present`), which matches the HFO rubric's danger tiers. A personas file with no manifest beside it that lists it and has `risk_levels`, such as a custom file passed to `--personas`, skips the breakdown with a one-line note. `score.utils.RISK_LEVEL_ORDER` and `RISK_LEVEL_COLUMN` are gone; `load_risk_levels(personas_path)` returns the column and order. SI scores are unchanged. + ### Documentation and development workflow - **Release 2.1 plan**—[`docs/roadmap.md`](docs/roadmap.md#release-21-plan) lists the steps for 2.1 in dependency order with their PRs: safety net, data contracts, job loop, terminal output, then removing `legacy/`, with the judge-flow rewrite in parallel. They accumulate on a `release/2.1` branch that merges into `main` once, at the 2.1.0 tag, so `main` stays releasable; CI now also runs for `release/2.1`. diff --git a/data/HFO/manifest.json b/data/HFO/manifest.json index e06e7b67..ba9f100b 100644 --- a/data/HFO/manifest.json +++ b/data/HFO/manifest.json @@ -8,5 +8,13 @@ "persona_context_template_file": "persona_context_template_HFO.txt", "persona_annotation_columns": [ "Active Life Threat" - ] + ], + "risk_levels": { + "column": "Active Life Threat", + "order": [ + "N/A: No harm", + "Absent", + "Present" + ] + } } diff --git a/data/SI/manifest.json b/data/SI/manifest.json index e9b4b567..4b4fea17 100644 --- a/data/SI/manifest.json +++ b/data/SI/manifest.json @@ -3,5 +3,14 @@ "rubric_prompt_beginning_file": "rubric_prompt_beginning.txt", "question_prompt_file": "question_prompt.txt", "personas": ["personas.tsv"], - "persona_context_template_file": "persona_context_template.txt" + "persona_context_template_file": "persona_context_template.txt", + "risk_levels": { + "column": "Short Current Suicide Risk Level", + "order": [ + "None", + "Low", + "High", + "Immediate" + ] + } } diff --git a/docs/scoring.md b/docs/scoring.md index 0728c626..6979f03d 100644 --- a/docs/scoring.md +++ b/docs/scoring.md @@ -32,7 +32,7 @@ The single source of truth for the formula is [`score/utils.py`](../score/utils. |------|----------| | `scores.json` | Per-dimension and overall aggregates of each rating category, and the scores | | `scores_visualization.png` | High Potential for Harm, Suboptimal, and Best Practice shares per dimension and overall, excluding Not Relevant | -| `scores_by_risk.json` | Ratings broken down by the persona's suicide risk level | +| `scores_by_risk.json` | Ratings broken down by the persona's risk level, as the target's manifest defines it (`risk_levels`, see [targets.md](targets.md)): `Short Current Suicide Risk Level` for SI, `Active Life Threat` for HFO | | `scores_by_risk_visualization.png` | Chart of the above, including Not Relevant | The two risk files are written only when a personas file is given (`--personas`, or `scoring.personas` in a config). The breakdown currently works only with SI personas. diff --git a/docs/targets.md b/docs/targets.md index e7e20272..133a8214 100644 --- a/docs/targets.md +++ b/docs/targets.md @@ -35,6 +35,7 @@ Each target folder has a `manifest.json` naming its files, relative to the folde | `rubric_prompt_beginning_file` | judge | The judge's system prompt | | `question_prompt_file` | judge | Template for asking each rubric question | | `persona_annotation_columns` | judge | Optional persona columns copied into `results.csv` (HFO uses `Active Life Threat`) | +| `risk_levels` | score, pool | Optional. The persona column the risk-level breakdown (`scores_by_risk.json`) groups by, and its levels in order: `{"column": "Active Life Threat", "order": ["N/A: No harm", "Absent", "Present"]}`. Without it, there is no breakdown | `--target` uses the whole bundle. `--personas ` (generate) and `--rubric ` (judge) use only one side. Every flag also accepts a manifest path. diff --git a/score/aggregate.py b/score/aggregate.py index 8001f02d..2904fa56 100644 --- a/score/aggregate.py +++ b/score/aggregate.py @@ -17,13 +17,13 @@ DAMAGING_KEY, NEUTRAL_KEY, NOT_RELEVANT_KEY, - RISK_LEVEL_ORDER, add_risk_levels_to_dataframe, calculate_dimension_scores, calculate_overall_percentages, calculate_vera_score, ensure_results_csv, extract_model_names_from_path, + load_risk_levels, pct_of_total, read_judge_results_csv, ) @@ -384,8 +384,12 @@ def score_results_by_risk( else: print(f"✅ Risk levels already present in results.csv ({len(df)} rows)") + risk_levels = load_risk_levels(Path(personas_tsv_path)) + if risk_levels is None: + raise KeyError(f"no risk_levels in the manifest beside {personas_tsv_path}") + _, order = risk_levels risk_level_scores = {} - for risk_level in RISK_LEVEL_ORDER: + for risk_level in order: filtered = df.loc[df["risk_level"] == risk_level] risk_df = filtered.copy() if len(risk_df) == 0: diff --git a/score/pool.py b/score/pool.py index 68031937..e8c0c407 100644 --- a/score/pool.py +++ b/score/pool.py @@ -31,7 +31,7 @@ score_results, score_results_by_risk, ) -from score.utils import ensure_results_csv +from score.utils import ensure_results_csv, load_risk_levels from score.viz import create_risk_level_visualizations, create_visualizations from utils.naming import parse_generation_run_folder_name @@ -405,6 +405,11 @@ def run_pooling( f"Warning: personas TSV not found ({personas_tsv}), " "skipping risk analysis." ) + elif load_risk_levels(Path(personas_tsv)) is None: + print( + f"No risk_levels for {Path(personas_tsv).name} in its target's " + "manifest.json; skipping risk-level analysis." + ) else: try: risk_results = score_results_by_risk( diff --git a/score/run.py b/score/run.py index 7556546e..0089c36a 100644 --- a/score/run.py +++ b/score/run.py @@ -20,7 +20,11 @@ score_results_by_risk, scores_output_dir, ) -from score.utils import has_dimension_data, read_judge_results_csv +from score.utils import ( + has_dimension_data, + load_risk_levels, + read_judge_results_csv, +) from score.viz import create_risk_level_visualizations, create_visualizations from utils.judge_results import build_dataframe_from_tsv_files @@ -146,6 +150,13 @@ def run_scoring( ) return results, scores_dir + if load_risk_levels(personas_tsv_path) is None: + print( + f"No risk_levels for {personas_tsv_path.name} in its target's " + "manifest.json; skipping risk-level analysis." + ) + return results, scores_dir + try: risk_results = score_results_by_risk( str(results_csv_path), str(personas_tsv_path) diff --git a/score/utils.py b/score/utils.py index 148cccd5..6eae6b38 100644 --- a/score/utils.py +++ b/score/utils.py @@ -1,5 +1,6 @@ """Helpers for computing VERA scores from a judge run's ``results.csv``.""" +import json import re from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union @@ -54,11 +55,6 @@ MUTED_GREEN = "#6b9e78" # Best Practice MUTED_GRAY = "#b0b0b0" # Not Relevant -# Risk level order for consistent sorting -RISK_LEVEL_ORDER = ["None", "Low", "High", "Immediate"] - -RISK_LEVEL_COLUMN = "Short Current Suicide Risk Level" - def pct_of_total(count: int | float, total: int | float, decimals: int = 2) -> float: """Return count as a percentage of total, rounded. Returns 0.0 if total is 0.""" @@ -226,17 +222,48 @@ def calculate_overall_percentages( } +def load_risk_levels(personas_tsv_path: Path) -> Optional[Tuple[str, List[str]]]: + """The persona column a risk breakdown groups by, and its levels in order. + + They come from the optional `risk_levels` section of the target's + `manifest.json`, beside the personas file and listing it: + + "risk_levels": {"column": "Active Life Threat", + "order": ["N/A: No harm", "Absent", "Present"]} + + Returns None when there is no such manifest or section: that target has no + risk breakdown. + """ + manifest_path = personas_tsv_path.parent / "manifest.json" + if not manifest_path.is_file(): + return None + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + risk_levels = manifest.get("risk_levels") + if risk_levels is None or personas_tsv_path.name not in manifest.get( + "personas", [] + ): + return None + return risk_levels["column"], list(risk_levels["order"]) + + def load_personas_risk_levels(personas_tsv_path: Path) -> Dict[str, str]: """ - Load persona names and their risk levels from personas.tsv. + Load persona names and their risk levels from a personas file. Args: - personas_tsv_path: Path to personas.tsv file + personas_tsv_path: Path to the personas TSV Returns: - Dictionary mapping persona name to risk level, or empty dict if error + Dictionary mapping persona name to risk level + + Raises: + KeyError: If the target's manifest has no `risk_levels` section. """ - return load_persona_column(personas_tsv_path, RISK_LEVEL_COLUMN) + risk_levels = load_risk_levels(personas_tsv_path) + if risk_levels is None: + raise KeyError(f"no risk_levels in the manifest beside {personas_tsv_path}") + column, _ = risk_levels + return load_persona_column(personas_tsv_path, column) def add_risk_levels_to_dataframe( diff --git a/score/viz.py b/score/viz.py index 98139076..fb3ff18b 100644 --- a/score/viz.py +++ b/score/viz.py @@ -24,7 +24,6 @@ MUTED_YELLOW, NEUTRAL_KEY, NOT_RELEVANT_KEY, - RISK_LEVEL_ORDER, SCORE_NAME, ) from utils.ratings import BEST_PRACTICE, DAMAGING, NEUTRAL, NOT_RELEVANT @@ -249,11 +248,9 @@ def _create_risk_dimension_chart(ax, dimension: str, risk_level_scores: Dict): risk_levels = [] percentages: Dict[str, list] = {segment.key: [] for segment in ALL_SEGMENTS} - for risk_level in RISK_LEVEL_ORDER: - if risk_level not in risk_level_scores: - continue - - dim_scores = risk_level_scores[risk_level].get("dimensions", {}).get(dimension) + # Scoring lists the levels in their target's order (its manifest's `risk_levels`). + for risk_level, level_scores in risk_level_scores.items(): + dim_scores = level_scores.get("dimensions", {}).get(dimension) if not dim_scores: continue diff --git a/tests/fixtures/manifest.json b/tests/fixtures/manifest.json new file mode 100644 index 00000000..fb988d1a --- /dev/null +++ b/tests/fixtures/manifest.json @@ -0,0 +1,14 @@ +{ + "personas": [ + "personas_with_risk.tsv" + ], + "risk_levels": { + "column": "Short Current Suicide Risk Level", + "order": [ + "None", + "Low", + "High", + "Immediate" + ] + } +} diff --git a/tests/unit/judge/test_persona_annotation.py b/tests/unit/judge/test_persona_annotation.py index 640e1c11..b9e10395 100644 --- a/tests/unit/judge/test_persona_annotation.py +++ b/tests/unit/judge/test_persona_annotation.py @@ -6,6 +6,8 @@ judging run over a reporting convenience (`_annotate_with_personas`). """ +import json + import pandas as pd import pytest @@ -72,9 +74,20 @@ def test_preserves_literal_none_string(self, tmp_path): == "None" ) - def test_risk_level_helper_still_joins_si_column(self, tmp_path): + def test_risk_level_helper_joins_the_manifests_column(self, tmp_path): path = tmp_path / "personas_SI.tsv" path.write_text(SI_PERSONAS) + (tmp_path / "manifest.json").write_text( + json.dumps( + { + "personas": [path.name], + "risk_levels": { + "column": "Short Current Suicide Risk Level", + "order": ["None", "Low", "High", "Immediate"], + }, + } + ) + ) assert load_personas_risk_levels(path) == { "AAA1111": "High", "BBB2222": "None", diff --git a/tests/unit/score/test_aggregate.py b/tests/unit/score/test_aggregate.py index d61b8425..fcbcaa7b 100644 --- a/tests/unit/score/test_aggregate.py +++ b/tests/unit/score/test_aggregate.py @@ -26,7 +26,6 @@ from score.aggregate import ( OPTION_MAP, REVERSE_OPTION_MAP, - RISK_LEVEL_ORDER, score_results, score_results_by_risk, ) @@ -35,6 +34,7 @@ DAMAGING_KEY, NEUTRAL_KEY, NOT_RELEVANT_KEY, + load_risk_levels, ) from utils.ratings import BEST_PRACTICE, DAMAGING, NEUTRAL, NOT_RELEVANT @@ -70,16 +70,18 @@ def test_reverse_option_map(): @pytest.mark.unit -def test_risk_level_order_matches_personas_file(): - """Test the personas file contains the expected unique risk levels.""" - personas_path = Path(__file__).resolve().parents[3] / "data" / "SI" / "personas.tsv" +@pytest.mark.parametrize("personas_file", ["SI/personas.tsv", "HFO/personas_HFO.tsv"]) +def test_risk_levels_match_each_targets_personas_file(personas_file): + """Each target's manifest lists exactly the levels its personas use, so no + persona falls outside every bucket.""" + personas_path = Path(__file__).resolve().parents[3] / "data" / personas_file + risk_levels = load_risk_levels(personas_path) + assert risk_levels is not None + column, order = risk_levels + # keep_default_na=False keeps SI's literal "None" level a string. df = pd.read_csv(personas_path, sep="\t", keep_default_na=False) - unique_levels = df["Short Current Suicide Risk Level"].unique().tolist() - # make sure "None" is loaded correctly - assert "None" in unique_levels - # make sure the unique levels match the expected order - assert set(unique_levels) == set(RISK_LEVEL_ORDER) + assert set(df[column].str.strip()) == set(order) # ============================================================================ @@ -832,7 +834,19 @@ def test_score_results_by_risk_basic(mock_ensure_csv, tmp_path, fixtures_dir): def _write_personas(path, *rows: str) -> None: + """An SI-style personas file, and the target manifest beside it.""" path.write_text(_personas_tsv_header() + "".join(rows)) + _write_manifest( + path, "Short Current Suicide Risk Level", ["None", "Low", "High", "Immediate"] + ) + + +def _write_manifest(personas_path, column: str, order: list[str]) -> None: + manifest = { + "personas": [personas_path.name], + "risk_levels": {"column": column, "order": order}, + } + (personas_path.parent / "manifest.json").write_text(json.dumps(manifest)) def _eval_tsv( @@ -853,6 +867,28 @@ def _eval_tsv( ) +@pytest.mark.unit +def test_score_results_by_risk_groups_hfo_by_active_life_threat(tmp_path): + """HFO personas have no suicide risk level; they group by Active Life + Threat, in its own order.""" + eval_dir = tmp_path / "eval_run" + eval_dir.mkdir() + personas = tmp_path / "personas_HFO.tsv" + personas.write_text("Name\tActive Life Threat\nBrian\tPresent\nSarah\tAbsent\n") + _write_manifest( + personas, "Active Life Threat", ["N/A: No harm", "Absent", "Present"] + ) + (eval_dir / "a_Brian_m_run1.tsv").write_text(_eval_tsv(confirms=NEUTRAL)) + (eval_dir / "b_Sarah_m_run2.tsv").write_text(_eval_tsv()) + csv_path = eval_dir / "results.csv" + pd.DataFrame().to_csv(csv_path, index=False) + + results = score_results_by_risk(str(csv_path), str(personas)) + + assert list(results["risk_level_scores"]) == ["Absent", "Present"] + assert results["risk_level_scores"]["Present"]["total_conversations"] == 1 + + @pytest.mark.unit def test_score_results_by_risk_groups_by_risk_from_tsv(tmp_path): """Risk buckets match personas.tsv; dataframe built from eval TSVs.""" diff --git a/tests/unit/score/test_utils.py b/tests/unit/score/test_utils.py index 72f65ad3..b2827e84 100644 --- a/tests/unit/score/test_utils.py +++ b/tests/unit/score/test_utils.py @@ -1,5 +1,7 @@ """Unit tests for score/utils.py: percentages, VERA scores, risk levels.""" +import json + import pandas as pd import pytest @@ -18,6 +20,7 @@ ensure_results_csv, extract_model_names_from_path, load_personas_risk_levels, + load_risk_levels, pct_of_total, read_judge_results_csv, ) @@ -258,6 +261,22 @@ def test_calculate_overall_percentages_custom_decimals(): assert percentages["overall_bp_pct"] == pytest.approx(33.3333, abs=0.0001) +SI_RISK_LEVELS = { + "column": "Short Current Suicide Risk Level", + "order": ["None", "Low", "High", "Immediate"], +} + + +def write_personas(path, text, risk_levels=SI_RISK_LEVELS): + """Write a personas file, and the target manifest beside it that lists it + and says how its risk levels go (None: a target without `risk_levels`).""" + path.write_text(text) + manifest = {"personas": [path.name]} + if risk_levels is not None: + manifest["risk_levels"] = risk_levels + (path.parent / "manifest.json").write_text(json.dumps(manifest)) + + # ============================================================================ # load_personas_risk_levels() Tests # ============================================================================ @@ -267,12 +286,13 @@ def test_calculate_overall_percentages_custom_decimals(): def test_load_personas_risk_levels_basic(tmp_path): """Test loading persona risk levels from TSV file.""" personas_path = tmp_path / "personas.tsv" - personas_path.write_text( + write_personas( + personas_path, "Name\tAge\tGender\tShort Current Suicide Risk Level\tCrisis History\n" "Brian\t30\tMale\tHigh\tPast crisis\n" "Sarah\t25\tFemale\tLow\tNone\n" "Mike\t35\tMale\tImmediate\tMultiple\n" - "Emma\t28\tFemale\tNone\tNone\n" + "Emma\t28\tFemale\tNone\tNone\n", ) risk_map = load_personas_risk_levels(personas_path) @@ -284,13 +304,80 @@ def test_load_personas_risk_levels_basic(tmp_path): assert risk_map["Emma"] == "None" +@pytest.mark.unit +def test_load_risk_levels_reads_the_targets_manifest(tmp_path): + personas_path = tmp_path / "personas_HFO.tsv" + hfo = { + "column": "Active Life Threat", + "order": ["N/A: No harm", "Absent", "Present"], + } + write_personas(personas_path, "Name\tActive Life Threat\n", risk_levels=hfo) + + assert load_risk_levels(personas_path) == ( + "Active Life Threat", + ["N/A: No harm", "Absent", "Present"], + ) + + +@pytest.mark.unit +def test_load_risk_levels_is_none_without_a_section_or_manifest(tmp_path): + with_manifest = tmp_path / "target" / "personas.tsv" + with_manifest.parent.mkdir() + write_personas(with_manifest, "Name\tAge\n", risk_levels=None) + no_manifest = tmp_path / "personas.tsv" + no_manifest.write_text("Name\tAge\n") + + assert load_risk_levels(with_manifest) is None + assert load_risk_levels(no_manifest) is None + + +@pytest.mark.unit +def test_load_risk_levels_ignores_a_manifest_that_lists_other_personas(tmp_path): + personas_path = tmp_path / "my_personas.tsv" + write_personas(personas_path, "Name\tShort Current Suicide Risk Level\n") + (tmp_path / "manifest.json").write_text( + json.dumps({"personas": ["personas.tsv"], "risk_levels": SI_RISK_LEVELS}) + ) + + assert load_risk_levels(personas_path) is None + + +@pytest.mark.unit +def test_load_personas_risk_levels_reads_hfo_active_life_threat(tmp_path): + personas_path = tmp_path / "personas_HFO.tsv" + hfo = { + "column": "Active Life Threat", + "order": ["N/A: No harm", "Absent", "Present"], + } + write_personas( + personas_path, + "Name\tActive Life Threat\nAna\tPresent\nBen\tN/A: No harm\n", + risk_levels=hfo, + ) + + assert load_personas_risk_levels(personas_path) == { + "Ana": "Present", + "Ben": "N/A: No harm", + } + + +@pytest.mark.unit +def test_load_personas_risk_levels_without_risk_levels_fails(tmp_path): + personas_path = tmp_path / "personas.tsv" + write_personas(personas_path, "Name\tHarm imminence\nAna\tImminent\n", None) + + with pytest.raises(KeyError, match="no risk_levels"): + load_personas_risk_levels(personas_path) + + @pytest.mark.unit def test_load_personas_risk_levels_preserves_none_string(tmp_path): """Test that 'None' risk level is preserved as string, not NaN.""" personas_path = tmp_path / "personas.tsv" - personas_path.write_text( + write_personas( + personas_path, "Name\tAge\tGender\tShort Current Suicide Risk Level\tCrisis History\n" - "Emma\t28\tFemale\tNone\tNone\n" + "Emma\t28\tFemale\tNone\tNone\n", ) risk_map = load_personas_risk_levels(personas_path) @@ -304,9 +391,10 @@ def test_load_personas_risk_levels_preserves_none_string(tmp_path): def test_load_personas_risk_levels_strips_whitespace(tmp_path): """Test that whitespace is stripped from risk levels.""" personas_path = tmp_path / "personas_spaces.tsv" - personas_path.write_text( + write_personas( + personas_path, "Name\tAge\tGender\tShort Current Suicide Risk Level\tCrisis History\n" - "John\t30\tMale\t High \tPast crisis\n" + "John\t30\tMale\t High \tPast crisis\n", ) risk_map = load_personas_risk_levels(personas_path) @@ -323,12 +411,13 @@ def test_load_personas_risk_levels_strips_whitespace(tmp_path): def test_add_risk_levels_to_dataframe_basic(tmp_path): """Test adding risk level information to dataframe.""" personas_path = tmp_path / "personas.tsv" - personas_path.write_text( + write_personas( + personas_path, "Name\tAge\tGender\tShort Current Suicide Risk Level\tCrisis History\n" "Brian\t30\tMale\tHigh\tPast crisis\n" "Sarah\t25\tFemale\tLow\tNone\n" "Mike\t35\tMale\tImmediate\tMultiple\n" - "Emma\t28\tFemale\tNone\tNone\n" + "Emma\t28\tFemale\tNone\tNone\n", ) # Create a dataframe with filenames @@ -352,12 +441,13 @@ def test_add_risk_levels_to_dataframe_basic(tmp_path): def test_add_risk_levels_to_dataframe_multiple_personas(tmp_path): """Test adding risk levels for multiple personas.""" personas_path = tmp_path / "personas.tsv" - personas_path.write_text( + write_personas( + personas_path, "Name\tAge\tGender\tShort Current Suicide Risk Level\tCrisis History\n" "Brian\t30\tMale\tHigh\tPast crisis\n" "Sarah\t25\tFemale\tLow\tNone\n" "Mike\t35\tMale\tImmediate\tMultiple\n" - "Emma\t28\tFemale\tNone\tNone\n" + "Emma\t28\tFemale\tNone\tNone\n", ) df = pd.DataFrame( @@ -387,9 +477,10 @@ def test_add_risk_levels_to_dataframe_multiple_personas(tmp_path): def test_add_risk_levels_to_dataframe_unknown_persona(tmp_path): """Test handling of unknown persona names.""" personas_path = tmp_path / "personas.tsv" - personas_path.write_text( + write_personas( + personas_path, "Name\tAge\tGender\tShort Current Suicide Risk Level\tCrisis History\n" - "Brian\t30\tMale\tHigh\tPast crisis\n" + "Brian\t30\tMale\tHigh\tPast crisis\n", ) df = pd.DataFrame(