Metadata-Version: 2.4
Name: personasurvey
Version: 0.2.14
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"
Provides-Extra: runpod
Requires-Dist: runpod>=1.7; extra == "runpod"
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,
    # These are the tuned RunPod defaults: 400 direct requests per pod,
    # shared queue, max_tokens=800, reasoning_effort="low", temperature=0,
    # and JSON mode. Pass an argument only to override a default.
    retries=3,
    timeout=180,
    stall_timeout=60,
)

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

With two endpoints, the RunPod defaults use 400 direct requests per pod (800
total). The shared queue lets either healthy Pod take the next person, while
the progress bar reports global requests per second and an ETA.

## 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 output and console monitoring

`run_simulation(...)` returns a `SimulationResult`. Its three main attributes are:

| Attribute | Description |
|---|---|
| `run.results` | One row per person with the model response, parsed fields, status, endpoint, tokens, latency, and any error. |
| `run.merged` | The original DataFrame with the simulation result columns attached, ready for analysis or export. |
| `run.summary` | Global totals for successful and failed requests, elapsed time, RPS, average latency, token counts, and optional cost. |

By default, `run_simulation(...)` displays one `tqdm` progress bar and one
final summary. It never writes periodic per-pod lines, so the bar stays clean
during long RunPod requests. The bar shows global RPS and completed persons per
pod; HTTP client request logs are hidden by default. Set `show_progress=False`
for fully silent runs, or `quiet_http_logs=False` if you need HTTP debug logs.

## Stop selected RunPod Pods after experiments

Install the optional control dependency with `pip install "personasurvey[runpod]"`.
Set `RUNPOD_API_KEY` and `RUNPOD_POD_IDS` before starting Jupyter. The latter is
a comma-separated list of the exact Pod IDs used for the experiment; it is not
derived from proxy URLs, and the package never stops every Pod in your account.

```python
from personasurvey import RunPodPodController

# Call this only after every planned run_simulation(...) call has succeeded.
RunPodPodController.from_environment().stop()
```

This uses the official RunPod Python SDK, whose Pod-control functions already
wrap RunPod's GraphQL API. GraphQL is appropriate for the infrequent lifecycle
operation; the high-volume model requests continue through the Pods' vLLM HTTP
endpoints.

## 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`.
