Drive DESeq Desk from your own code
A review of the statistics, not of the biology. The model reads what your browser (or your script) computed from the counts; it never sees the counts, never states what a gene does and never recomputes a number. "Sound" means the design is replicated and nothing flagged at medium or high undermines the calls - not that a gene has any function, and a gene without an adjusted p-value is not evidence of no change.
Everything the web page does is available over HTTP. Run the analysis with the page's own
deseq.js and deseqkit.js (pydeseq2 0.5.4 conventions: the 10-count gene
filter, median-of-ratios size factors, gene-wise, trend and MAP dispersions, IRLS fold changes,
Cook's outlier replacement and refit, the Wald test, Cook's and independent filtering with
Benjamini-Hochberg adjustment and optional LFC shrinkage, minimised with a port of scipy's L-BFGS-B),
send the facts, and get back a verdict (sound, caveated,
unreliable) and either a review of every metric and of the result or a pydeseq2 script
that reproduces it and runs follow-up checks. The natural loop: load the counts, review, script,
change the design or a filter, re-check. DESeq Desk is derived from the agent skill
@k-dense-ai/pydeseq2 (k-dense-ai/scientific-agent-skills, skill author K-Dense Inc.)
and its scripts/run_deseq2_analysis.py.
Two lanes: the task field
| task | returns | extra input |
|---|---|---|
review | A reading of every metric, what the result shows (how many genes, which direction, the strongest calls by id), changes to try (min_counts, a batch term, leaving out a named sample, shrinkage, a thresholded test, alpha, a VST PCA, more replicates), your claims judged against the facts, a methods paragraph stating the exact design, contrast, filter and alpha, and what the result cannot show. | none |
script | The fixes and one complete Python script: COUNTS and METADATA read with pd.read_csv(..., index_col=0), the same alignment, reference-first categories and count filter, DeseqDataSet and DeseqStats with every browser setting as a literal, lfc_shrink exactly when the browser shrank, an EXPECTED dict of browser values checked with math.isclose, then the fixes, all inside main(). | decision: the text of an earlier review run (optional) |
Input fields
| field | required | meaning |
|---|---|---|
task | yes | "review" or "script". An unknown value makes the model pick the closest lane and say which in lane. |
facts | yes | A JSON-encoded string holding the browser's DESeq2 run - see below. The page builds it with DeseqKit.buildInput. The counts themselves are never sent. |
title | no | A label for the analysis. |
context | no | Your notes: the experiment, the samples, what you want to conclude (the page sends up to 2,500 characters). |
question | no | Answered in the first summary bullet (up to 1,500 characters). |
decision | no | Script lane only: the text of an earlier review (up to 5,000 characters). |
retry_note | no | Sent by the page on its one reformat retry after a malformed reply. |
The facts string
facts is a JSON string, not an object. It holds
settings (pydeseq2_version 0.5.4; design, e.g.
~batch + condition; condition_column, reference,
test and contrast; batch_column; levels with the
reference first; design_columns; min_counts; alpha;
refit_cooks, cooks_filter, independent_filter;
lfc_shrink and shrink_coeff; the file names, separators, orientation and
any skipped featureCounts columns; runner_equivalent and
runner_differences; for synthetic data synthetic), samples
(sample, condition, batch, size factor, library size), metrics (M1..:
samples, genes tested, size-factor range, significant genes up and down, significant with
|log2FC| > 1, the dispersion trend, the prior variance, dispersion outliers, Cook's outliers, the
independent-filtering threshold, the p-value histogram, the shrinkage prior and, for synthetic data,
true and false positives), flags (F1.., severity high / medium / low),
top_genes (up to 20 by padj), significant_total,
browser_verdict and expected - the values a pydeseq2 reproduction must
match: sample and gene counts, the significant counts, every size factor, the trend coefficients, the
prior variance and baseMean, log2FoldChange, pvalue and padj for the top five genes.
To copy the facts without writing code, run DESeq2 on the page and press Report .json:
the file carries the exact facts object under facts, next to the full results arrays. A
finished result's Download .json carries it too, under browser. Send
it back as a string: json.dumps(facts), JSON.stringify(facts) or your
language's equivalent.
Building the body
To build a body that matches the page byte for byte, run the page's own modules in Node 18 or later:
save detmath.js, lbfgsb.js, deseq.js, ioparse.js
and deseqkit.js next to the script below. All five export themselves with
module.exports.
// make-body.js - build the exact body the page sends, with the page's own code.
// node make-body.js counts.csv samples.csv condition control treated review "Title" "notes" > body.json
const fs = require("fs");
const IO = require("./ioparse.js"), D = require("./deseq.js"), K = require("./deseqkit.js");
const [countsFile, sheetFile, condCol, ref, test, lane = "review", title = "", context = "", batchCol = ""] = process.argv.slice(2);
const sheet = IO.parseSheet(fs.readFileSync(sheetFile, "utf8"));
const counts = IO.parseCounts(fs.readFileSync(countsFile, "utf8"), sheet.ids);
const al = IO.align(counts, sheet, condCol, batchCol || null);
const s = { condCol, batchCol, ref, test, minCounts: 10, alpha: 0.05, shrink: true, cooksFilter: true, independentFilter: true, refitCooks: true };
const res = D.run(Object.assign({ genes: al.genes, counts: al.counts, samples: al.samples, cond: al.cond, batch: al.batch }, s, { batchCol: batchCol || "batch" }));
const X = K.analyze(res, { al, settings: s, title, duplicateGenes: IO.duplicates(al.genes),
parsed: { countsName: countsFile, sheetName: sheetFile, sampleIdColumn: sheet.idColumn, orientation: counts.orientation,
featureCounts: counts.featureCounts, skipped: counts.skipped, countsSep: counts.delim, sheetSep: sheet.delim } });
const body = K.mustBeObject(K.buildInput(X, { lane, title, context }));
fs.writeFileSync("deseq2_results.csv", K.resultsCsv(X, false)); // pydeseq2 results_df layout
console.error("browser verdict:", X.hint, "| tested:", X.n, "| significant:", X.nSig, "| flags:", X.flags.map(f => f.id + " " + f.category).join(", "));
console.error("idempotency key: deseq-desk:" + body.task + ":" + K.hashInput(body) + ":a1");
process.stdout.write(JSON.stringify(body));
# Or build the body in any language from a facts object you already hold, for example the
# "facts" key of the page's "Report .json" download. facts must go in as a STRING.
import json
report = json.load(open("deseq-desk-knockdown-vs-control-batch-adjusted-report.json")) # the page's download
body = {
"task": "review",
"title": "Knockdown vs control, batch-adjusted",
"context": "Four knockdown and four control cultures, sequenced in two batches ...",
"question": "Is adjusting for batch the right design here?",
"facts": json.dumps(report["facts"], separators=(",", ":")),
}
json.dump(body, open("body.json", "w"))
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}
The token is minted for this app (the guest endpoint takes {"slug":"deseq-desk"} in
its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ....
The input object IS the request body. There is no {"input": ...}
wrapper. A wrapped body is answered with an unknown field 'input' warning, and the
model never sees your text.
Error codes
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. A tiny client
One helper that sends the token, unwraps data and raises on ok: false.
The token comes from the token page (Copy token or
Copy shell export); step 2 covers the kinds of token and minting one from code.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="deseq-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://deseq-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "deseq-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://deseq-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
import { readFileSync } from "node:fs";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "deseq-desk";
// Paste the token from https://deseq-desk.skillsafe.ai/tokens.html into a file named "token",
// or replace the fallback with it.
let TOKEN = "YOUR_TOKEN";
try { TOKEN = readFileSync("token", "utf8").trim(); } catch {}
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "deseq-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://deseq-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class DeseqDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "deseq-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "deseq-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://deseq-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "deseq-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class DeseqDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "deseq-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
2. Get a token
The easiest route is the token page: it shows the token this browser
already holds, with Copy token and Copy shell export buttons, and
a sign-in button for a personal token. A guest token, minted with
POST /guest and {"slug":"deseq-desk"}, can call /me and
/estimate; the run is metered, so /run and /run-stream need
a personal token.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://deseq-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a run needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"deseq-desk"}'
# {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://deseq-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "deseq-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://deseq-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "deseq-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://deseq-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"deseq-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://deseq-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"deseq-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://deseq-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "deseq-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://deseq-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "deseq-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://deseq-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"deseq-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
3. Check the session and the balance
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await DeseqDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the run (free)
/estimate returns the model binding and the credits a run would reserve. It creates no
job and charges nothing. Expect model_alias gpt-terra and
markup_bps 1000 (a 10% markup). hold_credits is a
reservation, not the price: it is held against your balance while the run executes
and released afterwards. min_credits is the least balance that can start a run. What
you actually pay is charged_credits, reported on the finished job and in the
done event, and it is usually far lower than the hold. The body is the input object
itself, with no {"input": ...} wrapper. /estimate does not validate the
body, so check the shape yourself: an object whose every value is a string, task equal
to review or script,
facts non-empty, and facts a JSON string that parses to an object (this is
what the page's own guard, DeseqKit.mustBeObject, refuses to spend without).
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above, or by hand. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task") in ("review","script") and all(isinstance(v,str) for v in b.values()) and all(b.get(k,"").strip() for k in ("facts",)) and isinstance(json.loads(b["facts"]),dict)'
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
# "warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is the actual cost, usually far lower.
INPUT = json.load(open("body.json")) # built by make-body.js above, or by hand
assert isinstance(INPUT, dict) and INPUT.get("task") in ("review", "script")
assert all(isinstance(v, str) for v in INPUT.values())
assert all(INPUT.get(k, "").strip() for k in ("facts",))
assert isinstance(json.loads(INPUT["facts"]), dict) # facts is a JSON STRING
est = call("estimate", INPUT)
print(est["model_alias"], est["markup_bps"], est["hold_credits"], est.get("warnings"))
me = call("me")
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("top up first: balance is below min_credits")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // built by make-body.js above
if (!INPUT || typeof INPUT !== "object" || !["review", "script"].includes(INPUT.task)) throw new Error("task must be review or script");
for (const [k, v] of Object.entries(INPUT)) if (typeof v !== "string") throw new Error(k + " must be a string");
for (const k of ["facts"]) if (!(INPUT[k] || "").trim()) throw new Error(k + " is required");
JSON.parse(INPUT.facts); // throws unless facts is a JSON string
const est = await call("estimate", INPUT);
console.log(est.model_alias, est.markup_bps, est.hold_credits, est.warnings);
const me = await call("me");
if ((me.credits ?? 0) < est.min_credits) throw new Error("top up first");
raw, _ := os.ReadFile("body.json") // built by make-body.js above
var input map[string]string // every field is a string, facts included
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
if input["task"] != "review" && input["task"] != "script" {
panic("task must be review or script")
}
for _, k := range []string{"facts"} {
if strings.TrimSpace(input[k]) == "" {
panic(k + " is required")
}
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil {
panic("facts must be a JSON string holding an object")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
String input = Files.readString(Path.of("body.json")); // built by make-body.js above
if (!input.matches("(?s)\\s*\\{.*\"task\"\\s*:\\s*\"(review|script)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task review or script");
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"(review|script)\".*", "$1");
for (String k : new String[] {"facts"})
if (!input.contains("\"" + k + "\"")) throw new IllegalStateException(k + " is required");
String est = call("estimate", input);
System.out.println(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
INPUT = JSON.parse(File.read("body.json")) # built by make-body.js above
raise "task must be review or script" unless %w[review script].include?(INPUT["task"])
INPUT.each { |k, v| raise "#{k} must be a string" unless v.is_a?(String) }
%w[facts].each { |k| raise "#{k} is required" if INPUT[k].to_s.strip.empty? }
raise "facts must hold an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
est = call("estimate", INPUT)
puts est["model_alias"], est["markup_bps"], est["hold_credits"]
<?php
$input = json_decode(file_get_contents("body.json"), true); // built by make-body.js above
if (!is_array($input) || !in_array($input["task"] ?? "", ["review", "script"], true)) { throw new Exception("task must be review or script"); }
foreach ($input as $k => $v) { if (!is_string($v)) { throw new Exception("$k must be a string"); } }
foreach (["facts"] as $k) { if (trim($input[$k] ?? "") === "") { throw new Exception("$k is required"); } }
if (!is_array(json_decode($input["facts"], true))) { throw new Exception("facts must be a JSON string"); }
$est = call("estimate", $input);
echo $est["model_alias"], " ", $est["markup_bps"], " ", $est["hold_credits"], PHP_EOL;
var input = File.ReadAllText("body.json"); // built by make-body.js above
using var doc = JsonDocument.Parse(input);
var root = doc.RootElement;
var lane = root.GetProperty("task").GetString();
if (lane != "review" && lane != "script") throw new Exception("task must be review or script");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception($"{p.Name} must be a string");
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // facts is a JSON string
var est = await DeseqDesk.Call("estimate", root);
Console.WriteLine(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{id} until it is
terminal. The reply is a string at data.output.output: JSON.parse
it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the
attempt number, deseq-desk:<lane>:<hash>:a<attempt> (for example
deseq-desk:review:17z4spz1v70svb:a1), so a retried request returns the same job instead
of billing a second run. Use one key per distinct input: changed counts, settings or notes (so changed
facts) or a changed review are a new hash, the same data in the other lane is a new key, and replaying
an old key with a different body is a 409. The page uses
DeseqKit.hashInput(body) for the hash (it covers task, title,
context, facts, decision and question;
make-body.js prints the key); any stable digest of the body works from other
languages. Leave retry_note out of the hash and bump the attempt instead.
# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])') # review or script
KEY="deseq-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"lane\":\"review\",\"verdict\":\"caveated\",\"headline\":\"...\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"deseq-desk:{INPUT['task']}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `deseq-desk:${INPUT.task}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output; // the reply, as a string
console.log(job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("deseq-desk:%s:%x:a1", input["task"], sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "deseq-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
key = "deseq-desk:#{INPUT['task']}:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts job["charged_credits"], job["truncated"]
<?php
$key = "deseq-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var key = $"deseq-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await DeseqDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var output = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
6. Or stream it
POST /run-stream takes the same body and headers and answers with server-sent events:
job (the job id), delta (chunks of the reply) and done (the
status, charged_credits, truncated and, when present, the full
output). A browser page may receive only tick heartbeats and then
done, never a delta, so take the reply from done.output.output
when it is there, fall back to the concatenated deltas, and fall back again to
GET /jobs/{id}.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"lane\":\"review\",\"verdict\":\"caveated\",\"headline\":\"The"}
# event: done {"status":"succeeded","charged_credits":...,"truncated":false}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const streamed = done?.output?.output || raw; // browsers may get only ticks + done
console.log(done, streamed.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
The reply is a JSON object serialised as a string. Parse it, then check the lane.
# The reply is a JSON string inside data.output.output. Pull it out and parse it:
printf '%s' "$JOB" | python3 -c 'import sys,json;r=json.loads(json.load(sys.stdin)["output"]["output"]);print(r["verdict"],r["headline"])'
reply = json.loads(job["output"]["output"])
assert reply["lane"] == INPUT["task"], "the model answered as another lane"
print(reply["verdict"], reply["headline"])
for m in reply.get("metrics", []): # review lane
print(m["id"], m["reading"])
open("deseq_check.py", "w").write(reply.get("script", "")) # script lane
const reply = JSON.parse(job.output.output);
if (reply.lane !== INPUT.task) throw new Error("the model answered as another lane");
console.log(reply.verdict, reply.headline);
for (const m of reply.metrics || []) console.log(m.id, m.reading); // review lane
if (reply.script) require("fs").writeFileSync("deseq_check.py", reply.script); // script lane
var reply struct {
Lane, Verdict, Headline, Script string
Metrics []struct{ Id, Reading string }
}
if err := json.Unmarshal([]byte(job.Output.Output), &reply); err != nil { panic(err) }
fmt.Println(reply.Verdict, reply.Headline)
// With any JSON library (Jackson shown): the reply is a string that holds a JSON object.
JsonNode reply = new ObjectMapper().readTree(outputString);
System.out.println(reply.get("verdict").asText() + " " + reply.get("headline").asText());
reply = JSON.parse(job["output"]["output"])
raise "the model answered as another lane" unless reply["lane"] == INPUT["task"]
puts [reply["verdict"], reply["headline"]].join(" ")
$reply = json_decode($job["output"]["output"], true);
if ($reply["lane"] !== $input["task"]) { throw new Exception("the model answered as another lane"); }
echo $reply["verdict"], " ", $reply["headline"], "\n";
var reply = JsonSerializer.Deserialize<JsonElement>(outputString);
Console.WriteLine($"{reply.GetProperty("verdict")} {reply.GetProperty("headline")}");
Invariants worth asserting
- The reply is one JSON object whose
laneequals thetaskyou sent, with every key of that lane's contract present (empty arrays where there is nothing to say). - Every flag id in
facts.flagsis answered exactly once inprescan_responses, and nothing else is answered. - The verdict is never looser than
facts.browser_verdict(unreliable < caveated < sound) unless the medium or high flags that set it were dismissed. Low flags never set the verdict on their own. - In the review lane there is one reading per metric id (
M1.. in the order offacts.metrics),findingsis not empty, andmethodsstates the design formula, the test and reference levels, themin_countsfilter, alpha, pydeseq2 0.5.4 and the Benjamini-Hochberg adjustment. - Every number in the prose exists in
factsor your notes (after rounding to 3 significant figures; afractionmay be written as a percentage). Only a recommended new setting inparameters,fixesornext_stepsis exempt. No sentence says what a gene does, which pathway it is in or why it changed. - In the script lane
COUNTSandMETADATAaresettings.counts_fileandsettings.sample_sheet_file, both are read withpd.read_csv(..., index_col=0), the counts are transposed whensettings.transposeis true, genes are kept withcounts_df.sum(axis=0) >= min_counts, the condition is apd.Categoricalwithsettings.levels,DeseqDataSetcarries the design andrefit_cooksas literals,DeseqStatsthe contrast, alpha,cooks_filterandindependent_filteras literals,lfc_shrinkis called exactly whensettings.lfc_shrinkis true,EXPECTEDcarries every key offacts.expectedwith its value and is checked withmath.isclose, imports come only from pandas, numpy, pydeseq2,math,json,sys,warnings,pathliband matplotlib (Agg), nothing touches pickle, the network orplt.show(), and the work sits under a__main__guard. - The page's
recon.jschecks all of this; you can run it in Node the same way asdeseqkit.js(Recon.reconcile(Recon.normalize(Recon.parseResult(text), task), {input: body})). For synthetic data the page's counts.csv + samples.csv button saves the exact files the script expects; for your own data, keep the file names you loaded.
Worked example: review
A real request: the synthetic knockdown experiment from the page's first example (4 knockdown and 4
control samples in two batches, design ~batch + condition, shrunk fold changes). The
browser tests 2,854 genes, calls 133 significant and raises one low flag, so its read is
sound. The body, with facts abbreviated (send the full string from
make-body.js or the page's report):
{
"task": "review",
"title": "Knockdown vs control, batch-adjusted",
"context": "Four knockdown and four control cultures, sequenced in two batches with both conditions in each batch. I want to say the knockdown changes a few hundred genes and report the strongest ones.",
"facts": "{\"settings\":{\"pydeseq2_version\":\"0.5.4\",\"design\":\"~batch + condition\",\"contrast\":[\"condition\",\"knockdown\",\"control\"],\"levels\":[\"control\",\"knockdown\"],\"min_counts\":10,\"alpha\":0.05,\"lfc_shrink\":true,\"shrink_coeff\":\"condition[T.knockdown]\",\"counts_file\":\"counts.csv\",\"sample_sheet_file\":\"samples.csv\"},\"samples\":[{\"sample\":\"control_1\",\"condition\":\"control\",\"size_factor\":0.886628,\"library_size\":925418,\"batch\":\"b1\"},{\"sample\":\"control_2\",\"condition\":\"control\",\"size_factor\":1.26242,\"library_size\":1349309,\"batch\":\"b2\"},\"... 6 more\"],\"metrics\":[{\"id\":\"M1\",\"metric\":\"samples\",\"value\":[4,4],\"basis\":\"samples in the contrast: control (reference) then knockdown; all levels: control 4, knockdown 4; 8 samples, 3 design coefficients, 5 residual degrees of freedom\",\"residual_df\":5},{\"id\":\"M2\",\"metric\":\"genes_tested\",\"value\":2854,\"basis\":\"genes with at least 10 counts in total, of 3000 in the file (the script's --min-counts filter)\",\"fraction\":0.9513333333333334},\"... M3 to M13\"],\"flags\":[{\"id\":\"F1\",\"severity\":\"low\",\"category\":\"dispersion\",\"message\":\"The dispersion prior variance sits at its 0.25 floor.\",\"refs\":\"M7\"}],\"top_genes\":[{\"gene\":\"GENE1253\",\"baseMean\":16186.9,\"log2FoldChange\":3.9054,\"lfcSE\":0.1535,\"stat\":25.532,\"pvalue\":8.76e-144,\"padj\":2.306e-140,\"log2FoldChange_mle\":3.9167},\"... 19 more\"],\"significant_total\":133,\"browser_verdict\":\"sound\",\"expected\":{\"n_samples\":8,\"n_genes_tested\":2854,\"n_significant\":133,\"n_up\":73,\"...\":\"32 more\"},\"expected_count\":36}",
"question": "Is adjusting for batch the right design here?"
}
The reply the stand-in model gave during validation, which the page replays for free and which reconciles with zero disagreements:
{
"lane": "review",
"verdict": "sound",
"headline": "On this synthetic dataset, the batch-adjusted design calls 133 of 2854 tested genes significant (73 up, 60 down), recovering 128 of 297 known true changes with 5 false positives.",
"tldr": [
"Answer: Yes - both batches (b1, b2) contain both control and knockdown samples, so condition is not confounded with batch, and the ~batch + condition design correctly adjusts for it.",
"133 of the 2854 tested genes are significant at padj < 0.05 (73 up, 60 down in knockdown vs control) - a real but smaller call set than 'a few hundred'.",
"This run is on synthetic data (seed 20260927, 313 true DE genes among 3000 genes in the file), so these numbers describe pipeline recovery, not a real knockdown experiment.",
"The only browser flag (F1, low severity) notes the dispersion prior variance sits at its 0.25 floor; nothing is flagged medium or high, so the fit reads as sound.",
"The strongest calls by padj are GENE1253 (log2FoldChange 3.91, padj 2.31e-140), GENE1667 (log2FoldChange 2.98, padj 2.07e-60) and GENE0757 (log2FoldChange 2.43, padj 1.49e-57), all higher in knockdown."
],
"metrics": [
{
"id": "M1",
"reading": "Four control and four knockdown samples (8 total) were fit with 3 design coefficients (Intercept, batch[T.b2], condition[T.knockdown]), leaving 5 residual degrees of freedom."
},
{
"id": "M2",
"reading": "2854 of the 3000 genes in the file passed the min_counts=10 filter (about 95.1%) and were tested."
},
{
"id": "M3",
"reading": "Median-of-ratios size factors across the 8 samples range from 0.733 to 1.26, a max/min ratio of about 1.72 - a moderate, not extreme, depth spread."
},
{
"id": "M4",
"reading": "133 genes are called significant at padj < 0.05 (about 4.66% of the 2854 tested genes): 73 up and 60 down in knockdown vs control."
},
{
"id": "M5",
"reading": "Of the 133 significant genes, 92 also have a shrunk |log2FoldChange| greater than 1."
},
{
"id": "M6",
"reading": "The dispersion trend is parametric (a0=0.0253, a1=2.34), fit over 2241 genes in 3 iterations - an ordinary fit type rather than a mean fallback."
},
{
"id": "M7",
"reading": "The dispersion prior variance is 0.25, at pydeseq2's floor (the raw estimate - squared MAD of log residuals 0.5518 minus trigamma(5/2) - would be lower); this is what F1 flags."
},
{
"id": "M8",
"reading": "22 genes (about 0.771% of tested genes) have gene-wise dispersion more than 2 prior SDs above the trend and keep their gene-wise value instead of the MAP value."
},
{
"id": "M9",
"reading": "No genes were flagged as Cook's outliers (threshold F(0.99;3,5)=12.06), and since no design cell has 7 or more replicates, none were refit."
},
{
"id": "M10",
"reading": "Independent filtering set a baseMean threshold of 4.01 (the 7.76th percentile), removing 222 genes (about 7.78% of tested genes) that had a p-value from getting a padj."
},
{
"id": "M11",
"reading": "The p-value histogram across ten 0.1-wide bins (391, 251, 255, 280, 278, 264, 266, 311, 267, 291) shows a spike near zero and is otherwise roughly flat, consistent with genuine signal rather than a hill shape; 284 genes had a raw p-value below 0.05 before adjustment."
},
{
"id": "M12",
"reading": "LFC shrinkage used a Cauchy prior scale of 0.283 for condition[T.knockdown]; the reported log2FoldChange and lfcSE are shrunk while stat and pvalue come from the unshrunk fit."
},
{
"id": "M13",
"reading": "On the synthetic truth set, 128 of the 297 truly changed genes tested were recovered at padj < 0.05, with 5 false positives (about 3.76% false discovery share) and 169 true changes missed."
}
],
"findings": [
"This run is on synthetic data (seed 20260927; 313 of 3000 genes truly changed), so these counts describe pipeline recovery rather than a biological experiment.",
"133 of 2854 tested genes are significant at padj < 0.05 (73 up, 60 down in knockdown vs control), about 4.66% of tested genes; 92 of those also have |log2FoldChange| > 1 (shrunk).",
"The strongest calls by padj are GENE1253 (log2FoldChange 3.91, padj 2.31e-140), GENE1667 (log2FoldChange 2.98, padj 2.07e-60) and GENE0757 (log2FoldChange 2.43, padj 1.49e-57), all higher in knockdown.",
"Size factors range from 0.733 (knockdown_2) to 1.26 (control_2), a max/min of about 1.72, and library sizes (863,215 to 1,349,309) show no sample an order of magnitude off the rest.",
"Against the 297 truly changed genes tested (of 313 total), the pipeline recovered 128 with 5 false positives and missed 169."
],
"parameters": [
{
"change": "Run a variance-stabilized PCA (dds.vst(), then a PCA of dds.layers['vst_counts']) across the 8 samples.",
"why": "A suggestion to test whether the two batches hide any structure beyond what the ~batch + condition design already models; not required by any flag here.",
"refs": ""
},
{
"change": "Re-run DeseqStats with lfc_null=1 and alt_hypothesis='greaterAbs' instead of filtering the shrunk log2FoldChange post hoc.",
"why": "A suggestion to test: this directly asks which genes clear a fold-change floor with a formal test, closer to a 'genes that changed' claim than filtering the 133 significant calls by |log2FoldChange| > 1 (92 pass) after the fact.",
"refs": ""
}
],
"claims": [
{
"claim": "The knockdown changes a few hundred genes",
"support": "partly",
"why": "133 of the 2854 tested genes are significant at padj < 0.05 (73 up, 60 down) - a genuine, sizeable call set, but smaller than 'a few hundred' typically implies, and this run is on synthetic data rather than the described cultures."
}
],
"methods": "Differential expression was assessed with pydeseq2 0.5.4 using the design formula ~batch + condition on 8 samples (4 control, 4 knockdown), testing the contrast condition knockdown versus control (reference). Genes with fewer than 10 total counts were removed before fitting, leaving 2854 tested genes; size factors were estimated by the median-of-ratios method. Cook's distances were used for filtering and, in design cells with 7 or more replicates, for outlier refitting (refit_cooks=True), and independent filtering with Benjamini-Hochberg adjustment was applied at alpha=0.05 to call significance. Log2 fold changes and their standard errors were then shrunk with ds.lfc_shrink for the condition[T.knockdown] coefficient; reported test statistics and p-values are from the unshrunk fit.",
"cautions": [
"This run used synthetic data (seed 20260927; 313 of 3000 genes truly changed in the file), not a real culture experiment; treat these numbers as a pipeline check, not a biological result.",
"padj < 0.05 controls the expected share of false calls in the significant set as a whole, not the chance that any one gene is a false call, and says nothing about what a called gene does.",
"No gene lost a p-value to Cook's filtering here (0 outliers), but independent filtering removed padj for 222 genes with low baseMean (about 7.78% of tested genes); that is not evidence those genes are unchanged.",
"The significant-gene list and the 92-gene |log2FoldChange| > 1 subset are statistical calls only; no gene function, pathway or cause is stated here - an enrichment analysis is the next step for biological interpretation."
],
"next_steps": [
"Confirm with the app whether the counts and sample sheet were meant to be the real culture experiment - settings.synthetic shows this run's data is synthetic (seed 20260927).",
"Send the 133 significant genes (or the 92 with |log2FoldChange| > 1) to a pathway or enrichment tool for biological interpretation.",
"Keep the ~batch + condition design - each batch (b1, b2) contains both control and knockdown samples, so batch is not confounded with condition.",
"If a stricter effect-size cutoff is needed, rerun DeseqStats with lfc_null=1 and alt_hypothesis='greaterAbs' rather than filtering shrunk log2FoldChange values after the fact.",
"Only chase the 222 genes removed by independent filtering (M10) if there is prior interest in those specific low-count genes; otherwise treat their exclusion as expected at this depth."
],
"prescan_responses": [
{
"ref": "F1",
"verdict": "confirmed",
"note": "The dispersion prior variance is 0.25 and is at pydeseq2's floor (M7). This is a low-severity note about how much gene-wise dispersions can be pulled toward the trend; the dispersion trend, outlier counts and p-value histogram otherwise look ordinary, so it does not change the verdict."
}
]
}
Worked example: script
The same experiment handed to the script lane with the review above as decision. The
returned script was run with pydeseq2 0.5.4 on the page's counts.csv and
samples.csv and printed no EXPECTED mismatch. Body (abbreviated as above,
plus decision):
{
"task": "script",
"title": "Knockdown vs control, batch-adjusted",
"context": "I want a script that reproduces this analysis and then checks the samples for outliers with a VST PCA.",
"facts": "{\"settings\":{\"pydeseq2_version\":\"0.5.4\",\"design\":\"~batch + condition\",\"contrast\":[\"condition\",\"knockdown\",\"control\"],\"levels\":[\"control\",\"knockdown\"],\"min_counts\":10,\"alpha\":0.05,\"lfc_shrink\":true,\"shrink_coeff\":\"condition[T.knockdown]\",\"counts_file\":\"counts.csv\",\"sample_sheet_file\":\"samples.csv\"},\"samples\":[{\"sample\":\"control_1\",\"condition\":\"control\",\"size_factor\":0.886628,\"library_size\":925418,\"batch\":\"b1\"},{\"sample\":\"control_2\",\"condition\":\"control\",\"size_factor\":1.26242,\"library_size\":1349309,\"batch\":\"b2\"},\"... 6 more\"],\"metrics\":[{\"id\":\"M1\",\"metric\":\"samples\",\"value\":[4,4],\"basis\":\"samples in the contrast: control (reference) then knockdown; all levels: control 4, knockdown 4; 8 samples, 3 design coefficients, 5 residual degrees of freedom\",\"residual_df\":5},{\"id\":\"M2\",\"metric\":\"genes_tested\",\"value\":2854,\"basis\":\"genes with at least 10 counts in total, of 3000 in the file (the script's --min-counts filter)\",\"fraction\":0.9513333333333334},\"... M3 to M13\"],\"flags\":[{\"id\":\"F1\",\"severity\":\"low\",\"category\":\"dispersion\",\"message\":\"The dispersion prior variance sits at its 0.25 floor.\",\"refs\":\"M7\"}],\"top_genes\":[{\"gene\":\"GENE1253\",\"baseMean\":16186.9,\"log2FoldChange\":3.9054,\"lfcSE\":0.1535,\"stat\":25.532,\"pvalue\":8.76e-144,\"padj\":2.306e-140,\"log2FoldChange_mle\":3.9167},\"... 19 more\"],\"significant_total\":133,\"browser_verdict\":\"sound\",\"expected\":{\"n_samples\":8,\"n_genes_tested\":2854,\"n_significant\":133,\"n_up\":73,\"...\":\"32 more\"},\"expected_count\":36}",
"decision": "Verdict: sound.\nBatch-adjusted DESeq2 comparison of knockdown versus control calls 133 genes significant (73 up, 60 down) out of 2854 tested, a sound result with one low-severity flag.\n- 133 of 2854 tested genes are called significant at padj < 0.05, 73 up and 60 down in knockdown versus control (M4), about 4.66% of tested genes.\n- The strongest up call is GENE1253 (log2FoldChange 3.91, padj 2.31e-140, baseMean 16200), and the strongest down call by padj rank is GENE2444 (log2FoldChange -2.31, padj 2.33e-32).\n- Size factors range from 0.733 (knockdown_2) to 1.26 (control_2), a max/min of 1.72, an ordinary spread across the 8 samples (M3).\n- 92 of the 133 significant genes also pass |log2FoldChange| > 1 (M5), so most of the calls are large-effect.\n- On the synthetic truth set, 128 of 297 truly changed genes were recovered as significant with 5 false positives (M13), a 3.76% false discovery share among calls.\n- Try: Compare against a design without the batch term (~condition only) and see whether the significant gene count changes materially. (Directly tests whether adjusting for batch is doing real work here, answering the question of whether the batch term is warranted.)\n- Try: Run a thresholded test with lfc_null=1 and alt_hypothesis=\"greaterAbs\" in DeseqStats. (Focuses the call on the 92 genes with |log2FoldChange| > 1 (M5) rather than any nonzero change.)\n- Try: Run a variance-stabilized PCA (dds.vst(), then PCA of vst_counts) across the 8 samples. (Checks visually that samples separate by condition rather than by batch, supporting the ~batch + condition design.)\nNext steps:\n- Run a variance-stabilized PCA (dds.vst() then PCA) to confirm samples separate by condition rather than batch, supporting the batch + condition design.\n- Compare against a design without the batch term to see whether it changes the significant gene count.\n- Run a thresholded test with lfc_null=1 and alt_hypothesis=\"greaterAbs\" to focus on the 92 genes with |log2FoldChange| > 1 (M5).\n- Send the significant gene list to an enrichment or pathway analysis, since this result is statistical only.\n- Consider more replicates per condition in a follow-up experiment to raise the residual degrees of freedom above the current 5 (M1)."
}
Reply:
{
"lane": "script",
"verdict": "sound",
"headline": "This script reproduces the batch-adjusted knockdown-vs-control pydeseq2 0.5.4 run (133 of 2854 genes significant, M4) and adds a VST PCA to check the samples for outliers or batch structure, as requested.",
"tldr": [
"The script reads counts.csv and samples.csv, re-fits the ~batch + condition pydeseq2 0.5.4 model exactly as the browser did (design_columns Intercept, batch[T.b2], condition[T.knockdown]), and checks the 36 EXPECTED values (n_significant 133, M4) with math.isclose tolerances.",
"It also runs a VST PCA on the 8 samples, as you asked, to look for condition versus batch separation (4 control, 4 knockdown across batches b1/b2).",
"Two more re-runs are included: dropping the batch term, and a thresholded test at lfc_null=1 focused on the 92 large-effect calls (M5).",
"Verdict carried over from the review: sound, with one low-severity flag (F1) about the dispersion prior sitting at its 0.25 floor (M7)."
],
"fixes": [
{
"fix": "Run a variance-stabilized PCA: dds.vst() then a PCA (via numpy SVD on the centered vst_counts) of the 8 samples, printing PC1/PC2 by sample.",
"why": "Checks whether the 8 samples (4 control, 4 knockdown) separate by condition or by batch (b1, b2) on a variance-stabilized PCA, beyond the size factor spread already seen (M3: 0.733-1.26).",
"refs": ""
},
{
"fix": "Save the full results table to deseq2_results.csv and the padj < 0.05 subset to significant_genes.csv.",
"why": "Keeps the full results table (2854 tested genes, M2) and just the 133 significant genes (M4) available for downstream use.",
"refs": ""
},
{
"fix": "Print the top 20 genes by padj (baseMean, log2FoldChange, pvalue, padj) to the console.",
"why": "Gives a quick check against the review's top-gene table, for example GENE1253 (log2FoldChange 3.91, padj 2.31e-140).",
"refs": ""
},
{
"fix": "Save a volcano plot (log2FoldChange vs -log10 padj) as volcano.png using matplotlib's Agg backend.",
"why": "Gives a visual read on the 2854 tested genes (M2) and where the 133 significant calls (M4) fall.",
"refs": ""
},
{
"fix": "Re-run DeseqStats with design=\"~condition\" (no batch term) and report how the significant count changes.",
"why": "Tests whether the batch term (2 levels, b1/b2) is doing real work, against the 133 significant genes (M4) found with ~batch + condition.",
"refs": ""
},
{
"fix": "Re-run with a thresholded test: lfc_null=1.0 and alt_hypothesis=\"greaterAbs\" in DeseqStats, keeping the same design, and report the new significant count.",
"why": "Focuses the call on the 92 genes already passing |log2FoldChange| > 1 (M5) rather than any nonzero change.",
"refs": ""
}
],
"script": "import warnings\nimport sys\nimport math\nimport json\nfrom pathlib import Path\n\nimport pandas as pd\nimport numpy as np\nimport matplotlib\nmatplotlib.use(\"Agg\")\nimport matplotlib.pyplot as plt\n\nfrom pydeseq2.dds import DeseqDataSet\nfrom pydeseq2.ds import DeseqStats\nfrom pydeseq2.default_inference import DefaultInference\n\nCOUNTS = \"counts.csv\"\nMETADATA = \"samples.csv\"\n\nEXPECTED = {\n \"n_samples\": 8,\n \"n_genes_tested\": 2854,\n \"n_significant\": 133,\n \"n_up\": 73,\n \"n_down\": 60,\n \"size_factor__control_1\": 0.886627854248,\n \"size_factor__control_2\": 1.26241510223,\n \"size_factor__control_3\": 0.932685154622,\n \"size_factor__control_4\": 1.13362762897,\n \"size_factor__knockdown_1\": 1.14728149189,\n \"size_factor__knockdown_2\": 0.732541857301,\n \"size_factor__knockdown_3\": 1.04549178034,\n \"size_factor__knockdown_4\": 1.0539813074,\n \"trend_a0\": 0.02533435,\n \"trend_a1\": 2.338907,\n \"prior_disp_var\": 0.25,\n \"baseMean__GENE1253\": 16186.89507,\n \"log2FoldChange__GENE1253\": 3.9054175,\n \"pvalue__GENE1253\": 8.7603854e-144,\n \"padj__GENE1253\": 2.3057334e-140,\n \"baseMean__GENE1667\": 502.7724647,\n \"log2FoldChange__GENE1667\": 2.9812046,\n \"pvalue__GENE1667\": 1.5706904e-63,\n \"padj__GENE1667\": 2.0670285e-60,\n \"baseMean__GENE0757\": 1309.593327,\n \"log2FoldChange__GENE0757\": 2.4250098,\n \"pvalue__GENE0757\": 1.6964571e-60,\n \"padj__GENE0757\": 1.4883584e-57,\n \"baseMean__GENE0769\": 364.9591817,\n \"log2FoldChange__GENE0769\": 3.095341,\n \"pvalue__GENE0769\": 1.3373361e-53,\n \"padj__GENE0769\": 8.7996716e-51,\n \"baseMean__GENE0934\": 586.2522152,\n \"log2FoldChange__GENE0934\": 2.9803354,\n \"pvalue__GENE0934\": 1.1348048e-50,\n \"padj__GENE0934\": 5.9736123e-48,\n}\n\n\ndef load_data():\n counts_path = Path(COUNTS)\n metadata_path = Path(METADATA)\n if not counts_path.is_file():\n sys.exit(f\"Missing counts file: {COUNTS}\")\n if not metadata_path.is_file():\n sys.exit(f\"Missing sample sheet file: {METADATA}\")\n counts_df = pd.read_csv(counts_path, sep=\",\", index_col=0, comment=\"#\")\n counts_df = counts_df.drop(columns=[], errors=\"ignore\")\n counts_df = counts_df.T\n metadata = pd.read_csv(metadata_path, sep=\",\", index_col=0)\n shared = [s for s in counts_df.index if s in metadata.index]\n counts_df = counts_df.loc[shared]\n metadata = metadata.loc[shared]\n keep = metadata[\"condition\"].notna() & metadata[\"batch\"].notna()\n metadata = metadata.loc[keep]\n counts_df = counts_df.loc[metadata.index]\n metadata[\"batch\"] = metadata[\"batch\"].astype(str)\n metadata[\"condition\"] = pd.Categorical(\n metadata[\"condition\"].astype(str), categories=[\"control\", \"knockdown\"]\n )\n counts_df = counts_df.loc[:, counts_df.sum(axis=0) >= 10]\n return counts_df, metadata\n\n\ndef rerun(counts_df, metadata, design, contrast, lfc_null=None):\n dds2 = DeseqDataSet(\n counts=counts_df, metadata=metadata, design=design,\n refit_cooks=True, inference=DefaultInference(n_cpus=1),\n )\n dds2.deseq2()\n extra = {\"lfc_null\": lfc_null, \"alt_hypothesis\": \"greaterAbs\"} if lfc_null is not None else {}\n ds2 = DeseqStats(\n dds2, contrast=contrast, alpha=0.05, cooks_filter=True,\n independent_filter=True, inference=DefaultInference(n_cpus=1), **extra,\n )\n ds2.summary()\n return ds2.results_df\n\n\ndef main():\n warnings.filterwarnings(\"ignore\")\n counts_df, metadata = load_data()\n inference = DefaultInference(n_cpus=1)\n\n dds = DeseqDataSet(\n counts=counts_df,\n metadata=metadata,\n design=\"~batch + condition\",\n refit_cooks=True,\n inference=inference,\n )\n dds.deseq2()\n\n ds = DeseqStats(\n dds,\n contrast=[\"condition\", \"knockdown\", \"control\"],\n alpha=0.05,\n cooks_filter=True,\n independent_filter=True,\n inference=inference,\n )\n ds.summary()\n ds.lfc_shrink(coeff=\"condition[T.knockdown]\")\n\n res = ds.results_df\n mismatches = []\n\n def check(name, got, want, **kw):\n if not math.isclose(got, want, **kw):\n mismatches.append(f\"{name}: got {got}, expected {want}\")\n\n def check_count(name, got, want, tol=2):\n if abs(got - want) > tol:\n mismatches.append(f\"{name}: got {got}, expected {want} (tol {tol})\")\n\n n_samples = dds.n_obs\n n_genes_tested = len(res)\n sig_mask = res[\"padj\"] < 0.05\n n_significant = int(sig_mask.sum())\n n_up = int((sig_mask & (res[\"log2FoldChange\"] > 0)).sum())\n n_down = int((sig_mask & (res[\"log2FoldChange\"] < 0)).sum())\n\n if n_samples != EXPECTED[\"n_samples\"]:\n mismatches.append(f\"n_samples: got {n_samples}, expected {EXPECTED['n_samples']}\")\n if n_genes_tested != EXPECTED[\"n_genes_tested\"]:\n mismatches.append(f\"n_genes_tested: got {n_genes_tested}, expected {EXPECTED['n_genes_tested']}\")\n check_count(\"n_significant\", n_significant, EXPECTED[\"n_significant\"])\n check_count(\"n_up\", n_up, EXPECTED[\"n_up\"])\n check_count(\"n_down\", n_down, EXPECTED[\"n_down\"])\n\n size_factors = dds.obs[\"size_factors\"]\n for sample in metadata.index:\n key = f\"size_factor__{sample}\"\n if key in EXPECTED:\n check(key, float(size_factors[sample]), EXPECTED[key], rel_tol=1e-9)\n\n trend = dds.uns[\"trend_coeffs\"]\n check(\"trend_a0\", float(trend[\"a0\"]), EXPECTED[\"trend_a0\"], rel_tol=0.1)\n check(\"trend_a1\", float(trend[\"a1\"]), EXPECTED[\"trend_a1\"], rel_tol=0.1)\n check(\"prior_disp_var\", float(dds.uns[\"prior_disp_var\"]), EXPECTED[\"prior_disp_var\"], rel_tol=0.1)\n\n for gene in [\"GENE1253\", \"GENE1667\", \"GENE0757\", \"GENE0769\", \"GENE0934\"]:\n if gene not in res.index:\n mismatches.append(f\"{gene}: not found in results\")\n continue\n check(f\"baseMean__{gene}\", float(res.loc[gene, \"baseMean\"]), EXPECTED[f\"baseMean__{gene}\"], rel_tol=1e-9)\n check(f\"log2FoldChange__{gene}\", float(res.loc[gene, \"log2FoldChange\"]), EXPECTED[f\"log2FoldChange__{gene}\"], abs_tol=5e-3)\n got_p, want_p = float(res.loc[gene, \"pvalue\"]), EXPECTED[f\"pvalue__{gene}\"]\n check(f\"pvalue__{gene}\", -math.log10(max(got_p, 1e-300)), -math.log10(max(want_p, 1e-300)), rel_tol=0.05, abs_tol=0.05)\n got_a, want_a = float(res.loc[gene, \"padj\"]), EXPECTED[f\"padj__{gene}\"]\n check(f\"padj__{gene}\", -math.log10(max(got_a, 1e-300)), -math.log10(max(want_a, 1e-300)), rel_tol=0.05, abs_tol=0.05)\n\n if mismatches:\n print(\"EXPECTED mismatches:\")\n for m in mismatches:\n print(\" -\", m)\n else:\n print(\"No EXPECTED mismatches.\")\n print(json.dumps({\"n_significant\": n_significant, \"n_up\": n_up, \"n_down\": n_down}))\n\n # save results + significant genes\n res.to_csv(\"deseq2_results.csv\")\n sig = res[res[\"padj\"] < 0.05]\n sig.to_csv(\"significant_genes.csv\")\n print(f\"Saved deseq2_results.csv ({len(res)} genes) and significant_genes.csv ({len(sig)} genes).\")\n\n # top genes by padj\n top = res.sort_values(\"padj\").head(20)\n print(\"Top genes by padj:\")\n print(top[[\"baseMean\", \"log2FoldChange\", \"pvalue\", \"padj\"]])\n\n # volcano plot\n plt.figure(figsize=(6, 5))\n neglog10padj = -np.log10(res[\"padj\"].fillna(1.0).clip(lower=1e-300))\n plt.scatter(res[\"log2FoldChange\"], neglog10padj, s=5, alpha=0.5)\n plt.xlabel(\"log2FoldChange\")\n plt.ylabel(\"-log10(padj)\")\n plt.title(\"Volcano plot: knockdown vs control\")\n plt.savefig(\"volcano.png\", dpi=150)\n plt.close()\n print(\"Saved volcano.png\")\n\n # VST PCA for outliers / hidden batch structure\n dds.vst()\n vst_counts = np.asarray(dds.layers[\"vst_counts\"])\n vst_centered = vst_counts - vst_counts.mean(axis=0)\n u, s, vt = np.linalg.svd(vst_centered, full_matrices=False)\n pcs = u * s\n print(\"VST PCA (PC1, PC2) by sample:\")\n for i, sample in enumerate(metadata.index):\n cond = metadata[\"condition\"][sample]\n batch = metadata[\"batch\"][sample]\n print(f\" {sample} (condition={cond}, batch={batch}): PC1={pcs[i, 0]:.3f}, PC2={pcs[i, 1]:.3f}\")\n\n # re-run without the batch term\n res_nb = rerun(counts_df, metadata, \"~condition\", [\"condition\", \"knockdown\", \"control\"])\n n_sig_nb = int((res_nb[\"padj\"] < 0.05).sum())\n print(f\"Without batch term: {n_sig_nb} significant genes (was {n_significant} with ~batch + condition).\")\n\n # thresholded test lfc_null=1\n res_th = rerun(counts_df, metadata, \"~batch + condition\", [\"condition\", \"knockdown\", \"control\"], lfc_null=1.0)\n n_sig_th = int((res_th[\"padj\"] < 0.05).sum())\n print(f\"Thresholded test (lfc_null=1, greaterAbs): {n_sig_th} significant genes (was {n_significant} at lfc_null=0).\")\n\n\nif __name__ == \"__main__\":\n main()\n",
"assumptions": [
"counts.csv and samples.csv in the working directory are the exact files the page read, unmodified.",
"pydeseq2 0.5.4 is installed, matching settings.pydeseq2_version.",
"The values in counts.csv are raw integer read counts, not already normalized or transformed.",
"The sample and gene identifiers in the two files match exactly between counts.csv and samples.csv."
],
"checks": [
"The reproduction prints \"No EXPECTED mismatches.\"; a rare mismatch in trend_a0, trend_a1, prior_disp_var or a single gene's pvalue/padj can come from genes whose gene-wise dispersion sits at pydeseq2's lower dispersion bound, where its own fit is decided by rounding.",
"How n_significant, n_up and n_down (133, 73, 60, M4) move after the no-batch re-run and after the lfc_null=1 re-run.",
"Whether the VST PCA separates the 8 samples (4 control, 4 knockdown) by condition or by batch (b1, b2) rather than randomly.",
"That significant_genes.csv has the same row count as the n_significant the script prints."
],
"next_steps": [
"Run the script and confirm it prints no EXPECTED mismatch against the browser's 36 expected values.",
"Review the VST PCA printout to see whether the 8 samples separate by condition (4 vs 4) or by batch (b1/b2).",
"Compare the significant gene count with and without the batch term (currently 133, M4) to judge whether ~batch + condition is warranted.",
"Send the 133 significant genes (M4) to an enrichment or pathway analysis, since this result is statistical only.",
"Consider more replicates per condition in a follow-up experiment to raise the residual degrees of freedom above the current 5 (M1)."
],
"prescan_responses": [
{
"ref": "F1",
"verdict": "confirmed",
"note": "The dispersion prior variance sits exactly at its 0.25 floor (M7); the script's prior_disp_var check reproduces it. It is a low-severity flag and does not change the sound verdict, but genes near the floor are the likely source of any rare rounding-driven mismatch the script reports."
}
]
}