Metadata-Version: 2.4
Name: personasurvey
Version: 0.2.8
Summary: Reusable async persona survey simulations
License-Expression: MIT
Project-URL: Homepage, https://github.com/Japulgarin/personasurvey
Project-URL: Repository, https://github.com/Japulgarin/personasurvey
Project-URL: Issues, https://github.com/Japulgarin/personasurvey/issues
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pandas>=2.0
Requires-Dist: httpx>=0.25
Requires-Dist: openai>=1.0
Requires-Dist: tqdm>=4.0
Provides-Extra: gemini
Requires-Dist: google-genai>=1.0; extra == "gemini"
Provides-Extra: anthropic
Requires-Dist: anthropic>=0.40; extra == "anthropic"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Provides-Extra: analytics
Requires-Dist: duckdb>=1.0; extra == "analytics"
Dynamic: license-file

# personasurvey

Run persona surveys from a Pandas DataFrame. It creates prompts, sends async requests, saves JSONL checkpoints, resumes safely, and compares prompt experiments.

## Install

```bash
pip install personasurvey
pip install "personasurvey[analytics]"
```

## 1. Load people

```python
import pandas as pd

df = pd.read_csv("people.csv")
df["row_id"] = range(1, len(df) + 1)
df.head()
```

## 2. Create a prompt

```python
from personasurvey import PersonaPrompt, ResponseSchema

prompt = PersonaPrompt(
    name="usa_persona",
    demographic_cols=["state", "city", "age", "education_level", "occupation"],
    persona_detail_cols=["persona"],
    scenario="""Candidate A supports local health clinics.
Candidate B supports lower prescription prices.
How would this person divide support?""",
    response_schema=ResponseSchema.probabilities(["Candidate A", "Candidate B"]),
)

prompt.validate(df)
print(prompt.preview(df.iloc[0]))
```

For a vLLM prefix-cache experiment, create the same prompt with `persona_at_end=True`. All person-specific fields (`DEMOGRAPHICS` and `PERSONA DETAILS`) move after the static context and JSON instructions.

## 3. Choose a provider

```python
from personasurvey import RunPodProvider

provider = RunPodProvider(
    urls=[
        "https://your-pod-1.proxy.runpod.net/",
        "https://your-pod-2.proxy.runpod.net/",
    ],
    model="openai/gpt-oss-20b",
    api_key="EMPTY",
)
```

### Four or more RunPod pods

`more_than_2_pods` is the only extra option. It is `False` by default, so the
normal one- or two-pod call remains unchanged. With three or more URLs, set it
to `True` and the package selects its tested native-batch profile internally.

```python
POD_URLS = ["pod-1-url", "pod-2-url", "pod-3-url", "pod-4-url"]

provider = RunPodProvider(
    urls=POD_URLS,
    model="openai/gpt-oss-20b",
    api_key="EMPTY",
)

run = run_simulation(
    df=df,
    prompt=prompt,
    provider=provider,
    checkpoint="usa_persona.jsonl",
    max_tokens=800,
    reasoning_effort="low",
    temperature=0,
    json_mode=True,
    more_than_2_pods=True,
    retries=3,
    timeout=180,
)
```

Internally this uses batches of four, 24 batch requests per pod, a shared
queue, and a final retry batch containing only invalid people. For one or two
pods, omit `more_than_2_pods` entirely. When it is `True`, the profile also
overrides an old `concurrency=...` value so the call can otherwise stay intact.

```python
from personasurvey import OpenAIProvider, OpenRouterProvider, GeminiProvider, AnthropicProvider

local = OpenAIProvider(base_url="http://127.0.0.1:1234/v1", model="google/gemma-4-e2b", api_key="lm-studio")
openrouter = OpenRouterProvider(model="openai/gpt-oss-20b")
openai = OpenAIProvider(model="gpt-4.1-mini")
gemini = GeminiProvider(model="gemini-2.5-flash")
claude = AnthropicProvider(model="claude-sonnet-4-5")
```

## 4. Run

```python
from personasurvey import run_simulation

run = run_simulation(
    df=df,
    prompt=prompt,
    provider=provider,
    id_col="row_id",
    checkpoint="usa_persona.jsonl",
    n=5_000,
    concurrency=800,
    max_tokens=800,
    temperature=0,
    json_mode=True,
    retries=3,
    timeout=180,
    stall_timeout=60,
)

print(run.summary)
display(run.results.head())
```

`concurrency=800` with two endpoints means 400 in-flight requests per pod. Work is split evenly: 5,000 people becomes 2,500 per pod.

## 5. Read one result

```python
row = run.results.iloc[0]

print(row["input"])
print(row["output"])
print(row[["Candidate A", "Candidate B", "reason", "status", "in_tok", "out_tok"]])
```

`run.results` contains model data. `run.merged` contains the original person columns plus model data.

## 6. Continue

```python
from personasurvey import continue_simulation

run = continue_simulation(
    df=df,
    prompt=prompt,
    provider=provider,
    id_col="row_id",
    checkpoint="usa_persona.jsonl",
)
```

Successful IDs are skipped. Errors and pending rows are retried.

## Compare prompts

```python
from personasurvey import compare

comparison = compare(
    df,
    id_col="row_id",
    cultural="usa_cultural.jsonl",
    persona="usa_persona.jsonl",
)

display(comparison.data.head())
display(comparison.summary())
display(comparison.by("education_level"))
display(comparison.flips())
display(comparison.change_of_mind(n=10))
```

`compare()` detects numeric candidate fields, joins original population columns, creates winner columns, and never sends a model request.

```python
comparison.sql("""
    SELECT state,
           COUNT(*) AS people,
           AVG("Candidate A__persona" - "Candidate A__cultural") AS prompt_effect
    FROM comparison
    GROUP BY state
    ORDER BY prompt_effect DESC
""")
```

## Functions

| Function | Use |
|---|---|
| `PersonaPrompt(...)` | Define columns, scenario, and JSON response. |
| `prompt.preview(row)` | Print one exact prompt before running. |
| `run_simulation(...)` | Start a checkpointed experiment. |
| `continue_simulation(...)` | Resume the same JSONL checkpoint. |
| `compare(df, cultural=..., persona=...)` | Compare existing prompt experiments. |
| `comparison.by("state")` | Results by any population column. |
| `comparison.flips()` | People whose predicted winner changed. |
| `comparison.change_of_mind()` | Person, probabilities, and both model reasons. |
| `comparison.sql(...)` | Fast DuckDB query over the comparison. |

Examples: `examples/walkthrough.ipynb` and `examples/fictional_personas_100.csv`.
