Data Analysis Agent: Math First, AI Second

Data Analysis Agent

The Data Analysis Agent takes a CSV file, runs two statistical checks in Python, and passes the verified results to an AI model for interpretation. A CSV file is a spreadsheet-style file where each row is a record and each column is a field. Think of it like a simple Excel sheet saved in a format Python can read easily.

This agent does two jobs. First, it checks for unusual numbers using Z-score anomaly detection. A Z-score measures how far a number sits from the average. If most revenue days are around the same range and one day jumps far outside that range, the Z-score helps flag it.

Second, it runs a simple regression. Regression asks: when one number changes, does another number tend to change with it? In this build, the default example compares marketing spend against revenue.

Only after Python finishes the math does the AI model enter the picture. The model does not touch the raw data. It does not calculate the statistics. It receives the findings Python already computed and explains them in three sections: Insight, Confidence, and Next Steps.

That is the whole architecture: Python does the math. The model explains the math.


Why I Built This

Every data agent has a trust problem. If the language model reads the spreadsheet, does the math, and explains the result, the user has to trust that the model calculated correctly. That is not the architecture I want for business analysis.

A language model is excellent at explaining patterns in human language. It is not the tool I want acting as the calculator. For numbers that matter, I want Python doing the arithmetic because the same input produces the same computed output. So I split the work into two departments.

Python is the accounting department. It opens the spreadsheet, checks the numbers, runs the formulas, and writes down the findings. The language model is the communications department. It reads the findings and turns them into a business explanation a decision-maker can understand.

That separation is the point of the build. The operator sees the statistical findings before the AI explanation. That means the math is visible. If something looks wrong, you can stop and inspect the data before treating the model’s summary as useful.

This is the pattern I wanted to prove: let code calculate, let the model communicate.


What Actually Happened

What I expected was a heavier data science build with common libraries like pandas, numpy, statsmodels, and scipy.

What actually happened was better for this repo: the simplified version became stronger when those dependencies were removed. The agent now uses only Python’s standard library, which makes the pattern easier to run and easier to understand.

The first verified run completed the statistical calculations, but the Windows console crashed while printing the model response because the model returned a Unicode character the default console encoding could not handle. The fix was small:

sys.stdout.reconfigure(encoding="utf-8", errors="replace")

That keeps the agent from failing just because the model returns punctuation or formatting that Windows cannot print with the default encoding.

The older dependency-heavy version was reduced to one standard-library script. No pandas. No numpy. No OpenAI SDK. No python-dotenv. No LiteLLM Python package import.

What surprised me is that simple regression and anomaly detection can be explained clearly without turning the post into a statistics textbook. The business lesson is not “learn data science first.” The lesson is “separate calculation from explanation.”


The Architecture: How It Works

The agent is a single Python file with a straight-line workflow. It loads settings from .env, checks that required values exist, opens the CSV file, extracts numeric values from the selected columns, computes anomalies and regression, prints the statistical findings, and then asks the AI model to explain those findings in business language.

Think of the workflow like a restaurant. The kitchen is Python. It prepares the actual meal. It measures, cooks, and plates the food. The waiter is the language model. It explains what is on the plate and helps the customer understand it.

You would not want the waiter guessing what the kitchen cooked. The waiter should explain what the kitchen already prepared. That is what this agent does.

The Separation of Concerns

Separation of concerns means each part of the system has one clear job. In this agent, Python owns the numbers. Python loads the CSV, finds anomalies, calculates the regression slope, and calculates the R-squared value.

The model owns the explanation. It receives the computed findings and turns them into a clear business summary. It is specifically instructed not to invent additional math, probabilities, or extra metrics.

That division makes the system easier to review. If the numbers look wrong, inspect the Python and the CSV. If the explanation sounds weak, improve the prompt. The two problems are separate.

The Model-Agnostic Layer

The model call routes through a LiteLLM-compatible HTTP endpoint. The code does not import the OpenAI SDK. It does not import the LiteLLM Python package. It uses Python’s built-in HTTP tools to send a request to the configured endpoint.

The environment file controls these values:

LITELLM_BASE_URL
MODEL_NAME
LITELLM_API_KEY
DATA_FILE_PATH
BUSINESS_QUESTION
ANOMALY_COLUMN
ANOMALY_Z_THRESHOLD
REGRESSION_X_COLUMN
REGRESSION_Y_COLUMN

That means the model provider is configuration, not business logic. The agent’s core job is data analysis. The endpoint’s job is model routing. Keeping those responsibilities separate makes the code easier to move between providers.


The Build: Step by Step

Step 1: Define the project root and required settings

ROOT = Path(__file__).resolve().parent
REQUIRED_ENV = ("LITELLM_BASE_URL", "MODEL_NAME", "LITELLM_API_KEY", "DATA_FILE_PATH")

The agent starts by locating its own folder. That matters because the CSV path can be relative. If the .env file says the data lives at data/sample_sales_data.csv, the agent knows to look inside the project folder.

The REQUIRED_ENV list is the pre-flight checklist. These are the settings the agent must have before it can run.

Step 2: Load environment values

def load_env():
    env_file = ROOT / ".env"
    if not env_file.exists():
        return

    for line in env_file.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if line and not line.startswith("#") and "=" in line:
            key, value = line.split("=", 1)
            os.environ.setdefault(key.strip(), value.strip())

This function reads the .env file. Think of .env as the agent’s settings card. It tells the agent which model to use, where the CSV file lives, which column to analyze, and what business question to answer.

For example:

ANOMALY_COLUMN=revenue

That tells the agent to look for unusual values in the revenue column. The business value is simple: you can change the analysis setup without editing Python code.

Step 3: Load the CSV file

def read_csv(path):
    path = Path(path)
    path = path if path.is_absolute() else ROOT / path
    with path.open(newline="", encoding="utf-8") as file:
        return list(csv.DictReader(file))

This function opens the CSV file and turns it into rows Python can work with. csv.DictReader reads each row as a dictionary, which means the agent can access values by column name.

If your CSV has columns called marketing_spend and revenue, the code can read them like this:

row["marketing_spend"]
row["revenue"]

That is easier to understand than guessing which column number contains which value.

Step 4: Detect anomalies with Z-scores

def anomalies(rows, column, threshold):
    values = []
    for row_number, row in enumerate(rows, start=1):
        try:
            values.append((row_number, float(row[column])))
        except (KeyError, TypeError, ValueError):
            pass

    if not values:
        raise SystemExit(f"No numeric values found for column: {column}")

    numbers = [value for _, value in values]
    stdev = statistics.pstdev(numbers)
    if stdev == 0:
        return []

    mean = statistics.mean(numbers)
    return [
        {"row": row, "value": value, "z_score": round((value - mean) / stdev, 2)}
        for row, value in values
        if abs((value - mean) / stdev) >= threshold
    ]

This function looks for numbers that are unusually far from normal. The average is the center of the data. The standard deviation measures how spread out the numbers are. A Z-score tells you how many spread-units a value sits away from the average.

If the threshold is 3.0, the agent flags values that are at least three standard deviations away from the average. In business language, it is asking: which numbers are so far from the usual range that someone should look at them?

This is useful for unusual revenue days, abnormal expenses, inventory spikes, or sudden drops in activity.

Step 5: Run simple regression

def regression(rows, x_column, y_column):
    pairs = []
    for row in rows:
        try:
            pairs.append((float(row[x_column]), float(row[y_column])))
        except (KeyError, TypeError, ValueError):
            pass
    if len(pairs) < 2:
        raise SystemExit("The CSV needs at least two numeric rows for regression.")

    xs, ys = zip(*pairs)
    slope, _ = statistics.linear_regression(xs, ys)
    r_squared = statistics.correlation(xs, ys) ** 2
    return {
        "x_column": x_column,
        "y_column": y_column,
        "slope": round(slope, 4),
        "r_squared": round(r_squared, 4),
    }

This function compares two numeric columns. The default example is marketing spend and revenue. The question is: when marketing spend changes, does revenue tend to move with it?

The slope tells you the direction and size of the relationship. If the slope is positive, revenue tends to rise as marketing spend rises. If the slope is negative, revenue tends to fall as marketing spend rises.

The R-squared value tells you how much of the movement in one column is explained by the other column. A value closer to 1.0 means the relationship fits the data more strongly. A value closer to 0.0 means the relationship is weak.

This build does not calculate a p-value. It keeps the regression output focused on slope and R-squared so the architecture stays small and readable.

Step 6: Ask the model to explain, not calculate

def ask_model(question, findings):
    prompt = (
        "You are a business analyst. Python already computed these statistics. "
        "Do not invent more math, probabilities, or extra metrics. Explain the "
        "findings with three short sections: Insight, Confidence, Next Steps.\n\n"
        f"Business question: {question}\n"
        f"Computed findings:\n{json.dumps(findings, indent=2)}"
    )

This function gives the model a very specific job. It says: Python already computed the statistics. Do not invent more math. Do not invent probabilities. Do not invent extra metrics. Explain the findings in three short sections: Insight, Confidence, and Next Steps.

That is the key design choice. The model is not being asked to inspect the CSV. It is not being asked to calculate anomaly scores or regression. It is being asked to act like a business analyst reading a prepared report.

Step 7: Call the model through the configured endpoint

body = json.dumps(
    {
        "model": os.getenv("MODEL_NAME"),
        "messages": [{"role": "user", "content": prompt}],
        "temperature": 0.2,
    }
).encode("utf-8")

request = Request(
    os.getenv("LITELLM_BASE_URL").rstrip("/") + "/chat/completions",
    data=body,
    headers={
        "Authorization": "Bearer " + os.getenv("LITELLM_API_KEY"),
        "Content-Type": "application/json",
    },
)
with urlopen(request, timeout=60) as response:
    data = json.loads(response.read().decode("utf-8"))
return data["choices"][0]["message"]["content"].strip()

This part sends the explanation prompt to the model. It builds a request with the configured model name, the message, and a temperature of 0.2.

Temperature controls how much variation the model uses when writing. A lower value keeps the answer more focused, which is better for business analysis.

The function sends the request to the LiteLLM-compatible endpoint and returns the model’s response text. Again, the model receives computed findings, not raw authority over the spreadsheet.

Step 8: Package the computed findings

findings = {
    "row_count": len(rows),
    "anomaly_column": anomaly_column,
    "anomalies": anomalies(
        rows,
        anomaly_column,
        float(os.getenv("ANOMALY_Z_THRESHOLD", "3.0")),
    ),
    "regression": regression(
        rows,
        os.getenv("REGRESSION_X_COLUMN", "marketing_spend"),
        os.getenv("REGRESSION_Y_COLUMN", "revenue"),
    ),
}

This block creates the handoff document between Python and the model. It collects the row count, anomaly results, and regression results into one structured object called findings.

That object is printed before the model explains anything. That matters because the operator can see the raw computed results first. The AI explanation is not hiding the math. It is sitting on top of math the code already printed.

Step 9: Print the findings and the model explanation

print("STATISTICAL FINDINGS")
print(json.dumps(findings, indent=2))
print("\nANALYSIS RESULT")
print(ask_model(question, findings))

This is the final output. The agent first prints the computed statistics, then it prints the model’s business explanation. That order is important: the numbers come first, and the interpretation comes second.


Errors I Hit During This Build

The older version of this article included installation problems from statsmodels, scipy, numpy, and Python version compatibility. Those issues no longer apply to the current repo because the simplified build removed those dependencies.

The current implementation avoids that entire dependency chain by using only Python’s standard library. The real issue during verification was smaller and more practical: the agent successfully computed the statistics, then failed while printing the model response on Windows because the model returned a Unicode character the console could not encode.

The fix was to configure standard output for UTF-8:

sys.stdout.reconfigure(encoding="utf-8", errors="replace")

That is a useful reminder. Sometimes the math works, the model works, the endpoint works, and the failure is still a basic runtime detail.

The likely errors now are simpler. If LITELLM_BASE_URL, MODEL_NAME, LITELLM_API_KEY, or DATA_FILE_PATH is missing, the agent stops early and lists the missing value. If the selected anomaly column does not exist or contains no usable numbers, the agent stops with this message:

No numeric values found for column

Regression also needs pairs of numbers. If the selected X and Y columns do not produce at least two numeric pairs, the agent stops with this message:

The CSV needs at least two numeric rows for regression.

If the LiteLLM-compatible endpoint is not reachable or the credentials are wrong, the model explanation call fails after Python prints the statistical findings. That is still useful behavior because the operator can see that the local math worked before the model call failed.


Why This Matters

Every business generates data: sales figures, marketing spend, customer activity, inventory levels, support tickets, expenses, and delivery times. Most of that data sits in spreadsheets that nobody looks at systematically because analysis feels technical, slow, or dependent on someone else.

This agent demonstrates a more practical pattern. Python handles the repeatable calculations. The model turns those calculations into a business explanation. The operator gets both the numbers and the interpretation.

That matters for trust. If someone asks why the agent flagged a specific revenue day, the answer is not “the AI thought it looked unusual.” The answer is that the Python anomaly check calculated a Z-score above the configured threshold.

That is a much stronger explanation. It gives a founder, manager, or analyst something they can inspect, challenge, and improve.


What This Means

The real opportunity is not replacing analysts. The opportunity is giving more operators access to first-pass analysis.

A founder who cannot code can still understand the workflow: load the spreadsheet, calculate the findings, show the numbers, ask the model to explain the numbers, and review the result before making decisions.

That is a strong operating pattern because it keeps the model in the role where it is most useful. It translates, summarizes, and helps a human understand what the computed findings may mean. It does not become the source of the math.

For business use, that distinction is everything. The fastest path forward is not blind trust in AI analysis. It is building systems where the calculation is visible, the explanation is readable, and the decision still belongs to the human operator.


Tools Used

LiteLLM (MIT): github.com/BerriAI/litellm

pandas (BSD 3-Clause): github.com/pandas-dev/pandas

numpy (BSD 3-Clause): github.com/numpy/numpy

python-dotenv (BSD 3-Clause): github.com/theskumar/python-dotenv

NosisTech Agent Engineering (MIT): github.com/nosistech/agent-engineering


(c) 2026 NosisTech LLC. Licensed under CC BY 4.0. Use freely, just credit us.

WEEKLY BUILD NOTES

One documented agent build in your inbox, every week

Real systems, real code, the errors left in. No spam, unsubscribe anytime.