Dhrona1421/multimodal-content-moderation
π‘οΈ Multimodal Content Moderation Environment
OpenEnv RL Environment v2 Β· Meta Γ Hugging Face Γ PyTorch Hackathon
A production-grade reinforcement learning environment that simulates real-world social media content moderation β the same class of problem Meta's Trust & Safety teams solve at billions-of-posts-per-day scale. An agent observes posts (text + image classification + user trust metadata) and calls the standard OpenEnv API with a single action payload like {"action": "flag", "confidence": 0.78}.
π Table of Contents
- Problem Statement
- Architecture Overview
- Environment Specification
- Feature Extractor
- Policy Network
- Training Algorithm
- Reward System
- Novel Features
- Dataset
- Tasks
- Metrics
- Baseline Results
- Quick Start
- Validation
- API Reference
- File Structure
- Deployment
π― Problem Statement
Content moderation is one of the most consequential AI applications today:
- Platforms process hundreds of millions of posts per day
- Wrong decisions cause real harm β missed hate speech, undetected scams, false removal of legitimate content
- Human reviewers cannot scale; AI agents must make calibrated decisions and know when to escalate
- Moderation is inherently multimodal β text and image signals frequently conflict
This environment provides a reproducible RL sandbox for training and evaluating moderation agents across all of these challenges.
ποΈ Architecture Overview
Raw Post (text + image_tag + user_type + history)
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β features.py β 64-dim Multimodal Extractor β
β ββββββββββββ ββββββββββββ ββββββββββββββββββββ β
β β one-hot β β keyword β β cross-modal β β
β β encodingsβ β TF scoresβ β interaction termsβ β
β ββββββββββββ ββββββββββββ ββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β (64,) β [0,1]
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β network.py β Actor-Critic MLP (19,172 params)β
β 64β128(LN+Drop)β64(LN+Drop)β32 β
β β Actor head β Critic head β
β FC(3)βsoftmax β Ο(a|s) FC(1) β V(s) β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β action, confidence, value
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β env.py β ContentModerationEnv β
β Multi-objective reward Β· Severity weighting β
β Confidence-gated escalation Β· User history β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β reward β [0.0, 1.0]
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β train.py β PPO-Clip + GAE β
β Curriculum: easy β medium β hard β
β Adam + Cosine LR Β· KL early-stop β
βββββββββββββββββββββββββββββββββββββββββββββββββββπ Environment Specification
Observation Space
Action Space
The canonical step() input is a single action object:
Episode
- 12 steps per episode (posts sampled from task pool)
reset()β first observationstep(action_payload)β(obs, reward, done, info)state()β current observation without advancing (OpenEnv spec)
π§ Feature Extractor (64-dim)
features.py converts a raw observation dict into a 64-dimensional float32 vector where every value β [0, 1].
The cross-modal interaction terms are the key innovation β they explicitly encode the conflicting signals that make hard-tier posts difficult:
feat[31] = high_risk_img * safe_score # safe text + harmful image
feat[32] = trusted_user * misinfo_score # trusted user spreading misinfo
feat[33] = suspicious_usr * safe_score # suspicious user + safe content
feat[38] = mislead_img * trusted_user # trusted + misleading image
feat[39] = high_risk_img * suspicious_usr # highest-risk combination𧬠Policy Network
network.py implements a Deep Actor-Critic MLP in pure NumPy with a PyTorch-compatible API (direct port requires only replacing @ with torch.matmul).
Input (64)
β Linear(128) β LayerNorm β ReLU β Dropout(0.10)
β Linear(64) β LayerNorm β ReLU β Dropout(0.10)
β Linear(32) β ReLU
ββββββββββββββββββββββββββ
β Actor head β Linear(3) β Softmax β Ο(a|s) β
β Critic head β Linear(1) β V(s) β
ββββββββββββββββββββββββββ
Parameters: 19,172 | Init: He normal (actor/critic: Ο=0.01)Design choices:
- LayerNorm prevents internal covariate shift without batch statistics
- Small actor/critic init (Ο=0.01) gives uniform initial action probs
- Separate heads on shared trunk = standard Actor-Critic architecture
- Confidence output =
max(Ο(a|s))β directly interpretable
π Training Algorithm (PPO-Clip)
train.py implements Proximal Policy Optimisation with Clip (Schulman et al. 2017) in pure NumPy β no external RL libraries.
Algorithm
for stage in [easy, medium, hard]: # curriculum
for update in range(n_updates):
collect n_steps transitions # rollout
compute GAE advantages (Ξ»=0.95, Ξ³=0.99)
for epoch in range(4): # PPO epochs
for minibatch in shuffle(rollout): # 4 mini-batches
compute L_clip + c1Β·L_VF - c2Β·H # PPO objective
compute analytic gradients via backprop
clip gradients (max_norm=0.5)
Adam step (lr=3e-4, cosine annealed)
if KL > 0.02: early stop epoch # stabilityHyperparameters
Curriculum Learning
Training proceeds through three stages: easy β medium β hard. Each stage uses the dataset pool for that difficulty level and inherits the policy weights from the previous stage. This mirrors established curriculum learning practice and ensures the agent first learns obvious cases before facing adversarial edge cases.
π° Reward System
Multi-objective reward decomposition
reward = base_accuracy + severity_modifier + context_modifier + calibration_bonusBase accuracy matrix (correctaction, agentaction):
Severity amplifier β applied when agent allows harmful content:
Confidence-gated escalation (confidence < 0.45 β human review):
Reward range: [0.0, 1.0] Score range: [0.0, 1.0] (normalised over episode)
β¨ Novel Features
1. Confidence-Gated Human Escalation
When an agent's confidence < 0.45, the action is treated as "route to human review" rather than a committed decision. This earns partial credit proportional to difficulty β mirrors how Meta's actual Trust & Safety pipeline handles low-confidence cases. No other OpenEnv submission models this mechanism.
2. Cross-Modal Interaction Features
The feature extractor explicitly computes 10 product terms between image signals and text signals, directly encoding the conflicts that define hard-tier posts. A naive bag-of-words or one-hot approach misses these.
3. Severity-Weighted Reward
False negatives are not all equally bad. Allowing nudity content is penalised 1.5Γ harder than allowing misleading content β matching the real prioritisation hierarchy in Trust & Safety.
4. Multi-Metric Grading
Beyond accuracy, the grader computes:
- ECE (Expected Calibration Error) β is the agent's stated confidence reliable?
- FNR on high-risk content β the safety-critical miss rate
- Fairness gap β max accuracy disparity across
new/trusted/suspicioususers - Per-class F1 β does the agent know when to flag vs remove vs allow?
5. Vectorised Environment
VecContentModerationEnv runs N independent environments in lockstep for PPO batch rollout collection β standard in modern RL but unusual in OpenEnv submissions.
6. Decision Reasoning (The "Why")
The environment now captures and displays Agent Reasoning for every step. Agents evaluate:
- Image Signal: Direct visual violation check.
- User Signal: Contextual trust-level analysis.
- Text Signal: Semantic intent and keyword analysis.
- Cross-Modal Conflict: Detecting when text masks harmful imagery. This reasoning is visible live in the Auto-Pilot and Play tabs of the Gradio demo, transforming the agent from a "black box" into a transparent moderator.
7. Procedural Episode Variants
Medium and hard episodes apply deterministic, seed-controlled text perturbations (context distractors, conflicting clauses, light formatting noise). This prevents pure memorization of static samples while preserving reproducibility.
π Dataset (41 Posts)
Hand-crafted posts across 3 difficulty tiers designed to test reasoning, not keyword matching.
Hard-Tier Edge Cases
Distribution
π§© Tasks
from tasks import make_task
env = make_task("easy") # easy-only pool (14 posts), sampled to episode length
env = make_task("medium") # medium-only pool (13 posts), sampled to episode length
env = make_task("hard") # hard-only pool (14 posts), sampled to episode lengthπ Metrics
The grader (grader.py) computes the following for every agent Γ task combination:
π Baseline Results
All values below are verified against the bundled artifacts at seed=42, 12 steps per episode.
Verified score (primary metric, 0β1)
Additional metrics (Rule-Based, Hard task)
Reproduce the verified numbers with:
python inference_eval.py --rule-based --seed 42 --verbose
python train.py --eval-only --checkpoint ppo_checkpoint --seed 42Why does rule-based beat PPO? The reward function strongly penalizes confident false negatives on harmful content while still giving modest partial credit for cautious escalation. The shipped PPO checkpoint converges toward a conservative flag-heavy policy, which is safer than random but weaker than the hand-authored moderation heuristic. That is acceptable for this benchmark: the baseline is deterministic, reproducible, and the hard tier remains nontrivial for learned agents. External LLM scores are intentionally omitted from this fixed table because they depend on the provider, model, and token configuration.π Quick Start
Local (no Docker)
git clone <your-repo-url>
cd multimodal-content-moderation
python3.11 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
# Run strict hackathon inference loop (rule-based, no API key needed)
python inference.py --task hard --rule-based --seed 42 --max-steps 12
# Run with LLM agent
export API_BASE_URL=https://api.openai.com/v1
export MODEL_NAME=gpt-4o-mini
export HF_TOKEN=your_api_key_here
python inference.py --task hard --seed 42 --max-steps 12
# Single task
python inference.py --task hard --seed 42 --max-steps 12
# Full evaluation report (table metrics + results.json)
python inference_eval.py --rule-based --seed 42 --verbose
# Run through deployed/local HTTP API endpoints instead of direct env calls
python api_inference.py --base-url http://127.0.0.1:7860 --task hard --agent rule-based
# Run local submission smoke checks
python validate_submission.py
# Run the official OpenEnv validator
openenv validate --verbose
# Run the OpenEnv server entrypoint directly
uv run server
# Launch API-only server entrypoint
python api.py
# Train PPO from scratch (full curriculum)
python train.py --updates 200
# Evaluate saved checkpoint
python train.py --eval-only --checkpoint ppo_checkpoint
# Launch Gradio UI + API (default mode)
python app.py
# Force API-only mode from app.py if needed
OPENENV_API_ONLY=1 \
python app.pyDocker
# Build
docker build -t content-moderation-env .
# Run interactive demo (rule-based, no key needed)
docker run -p 7860:7860 content-moderation-env
# With LLM agent
docker run -e HF_TOKEN=your_key -p 7860:7860 content-moderation-env
# CLI strict inference
docker run content-moderation-env python inference.py --task hard --rule-based --seed 42 --max-steps 12
# Full evaluation report
docker run content-moderation-env python inference_eval.py --rule-based --seed 42 --verbose
# Submission smoke checks
docker run content-moderation-env python validate_submission.py
# Train inside container
docker run content-moderation-env python train.py --updates 200Hugging Face Spaces
- Create a new Space with the Docker SDK
- Push this repository
- Add the required runtime configuration:
- variable
API_BASE_URL - variable
MODEL_NAME - secret
HF_TOKEN - Restart the Space after saving variables/secrets
- Verify the public Space responds with
200onPOST /reset - The Space launches
app.pyautomatically on port 7860
π Plug in Your Own Agent
The grader accepts any callable that returns either an OpenEnv action payload or a legacy (action, confidence) tuple:
from grader import ModerationGrader
from features import extract_features
def my_agent(obs):
# obs keys: post_id, text, image_tag, user_type, difficulty,
# step, max_steps, user_history, session_stats, features (64,)
features = obs["features"] # pre-computed 64-dim feature list
action = "flag" # your logic here
confidence = 0.80 # calibrated confidence [0, 1]
return {"action": action, "confidence": confidence}
grader = ModerationGrader(seed=42)
report = grader.grade_all_tasks(my_agent)
grader.print_report(report, verbose=True)
print(f"Aggregate: {report['aggregate_score']:.4f}")π API Reference
ContentModerationEnv
env = ContentModerationEnv(
dataset_path = "moderation_dataset.json",
task = "hard", # easy | medium | hard
max_steps = 12,
seed = 42,
severity_scale = 0.3, # weight of severity penalty
calib_weight = 0.15, # weight of calibration bonus
)
obs = env.reset()
obs, r, done, info = env.step({"action": "flag", "confidence": 0.75})
obs = env.state() # OpenEnv spec: non-advancing read
score = env.compute_score()
print(env.render())OpenEnvModerationEnv (strict canonical API)
from openenv_env import OpenEnvModerationEnv
env = OpenEnvModerationEnv(task="hard", seed=42, max_steps=12)
obs = env.reset()
obs, reward, done, info = env.step({"action": "flag", "confidence": 0.8})
obs = env.state()VecContentModerationEnv
from env import VecContentModerationEnv
vec = VecContentModerationEnv(n_envs=4, task="hard", seed=0)
obs_list = vec.reset()
obs_list, rewards, dones, infos = vec.step(
actions=[
{"action": "allow", "confidence": 0.9},
{"action": "flag", "confidence": 0.7},
{"action": "remove", "confidence": 0.95},
{"action": "flag", "confidence": 0.6},
],
)ActorCriticNetwork
from network import ActorCriticNetwork
from features import extract_features
net = ActorCriticNetwork() # 19,172 parameters
net.load("ppo_final") # load checkpoint
feat = extract_features(obs) # (64,) ndarray
probs, value, cache = net.forward(feat) # probs sums to 1.0
action_idx, conf, val = net.act(feat, greedy=True)
net.save("my_checkpoint") # saves .npz fileModerationGrader
from grader import ModerationGrader
grader = ModerationGrader(seed=42)
# Grade one task
result = grader.grade_single_task("hard", my_agent)
print(result["score"]) # 0β1
print(result["classification"]) # per-class precision/recall/F1
print(result["confusion_matrix"]) # 3Γ3 list
print(result["fnr_high_risk"]) # false-negative rate on harmful content
print(result["fairness_gap"]) # accuracy gap across user types
# Grade all tasks
report = grader.grade_all_tasks(my_agent)
grader.print_report(report, verbose=True)
print(report["aggregate_score"])PPOTrainer
from train import PPOConfig, PPOTrainer, make_ppo_agent
from network import ActorCriticNetwork
cfg = PPOConfig()
cfg.n_steps = 64
cfg.n_epochs = 4
cfg.lr = 3e-4
net = ActorCriticNetwork()
trainer = PPOTrainer(net, cfg)
env = make_task("hard")
# One update cycle
rollout_stats = trainer.collect_rollout(env)
update_stats = trainer.update(rollout_stats)
# Wrap as grader-compatible agent
agent = make_ppo_agent(net, greedy=True)π File Structure
multimodal-content-moderation/
|-- moderation_dataset.json # 41 posts with ground-truth labels and reasons
|-- features.py # 64-dim multimodal feature extractor
|-- network.py # Deep Actor-Critic MLP + Adam optimiser
|-- env.py # OpenEnv-compliant RL environment (+ VecEnv)
|-- openenv_env.py # Strict reset()/step()/state() OpenEnv adapter
|-- tasks.py # Task registry and make_task() factory
|-- grader.py # Full grading engine with F1, ECE, FNR, fairness
|-- inference.py # Strict [START]/[STEP]/[END] submission inference runner
|-- inference_eval.py # Full local evaluation runner (tables + results.json)
|-- api_inference.py # HTTP /reset+/step+/state agent loop runner
|-- train.py # PPO-Clip trainer with GAE and curriculum learning
|-- app.py # API + optional 6-tab Gradio UI runtime
|-- api.py # Dedicated API-only entrypoint
|-- server/
| |-- __init__.py # OpenEnv server package exports
| `-- app.py # OpenEnv-compatible server entry point (main)
|-- __init__.py # Package init - public API exports
|-- pyproject.toml # OpenEnv packaging metadata + `server` script
|-- uv.lock # Locked dependency resolution for uv/openenv
|-- openenv.yaml # OpenEnv metadata specification
|-- requirements.txt # Python dependencies
|-- Dockerfile # Multi-stage production Docker build
|-- scripts/
| `-- validate-submission.sh # End-to-end HF + Docker + openenv validator
|-- LICENSE # MIT license
|-- ppo_checkpoint_best.npz # Bundled PPO checkpoint used by demo + evaluation
|-- ppo_checkpoint_final.npz # Final PPO checkpoint after training
|-- ppo_final.npz # Legacy bundled PPO checkpoint alias
|-- training_log.csv # Training metrics CSV (generated by train.py)
`-- results.json # Last evaluation results (generated by inference_eval.py)π’ Deployment
Hugging Face Spaces (recommended)
The Dockerfile is configured for HF Spaces:
- Exposes port 7860 (FastAPI API default)
- Serves validator-compatible HTTP endpoints:
POST /reset,POST /step,GET /state - Health-check validates environment integrity
- Bundles the trained PPO checkpoints used by the UI and CLI evaluation
API_BASE_URL,MODEL_NAME, andHF_TOKENenable the LLM agent path required by the hackathon- Falls back to rule-based agent automatically if no token
Environment Variables
Submission Validator
Run the local validator script against the public Space URL:
./scripts/validate-submission.sh https://dhrona1421-multimodal-content-moderation.hf.space .HTTP API
The deployed Space exposes validator-compatible API endpoints by default:
POST /resetwith optional JSON body:{"task":"easy|medium|hard","seed":42,"max_steps":12,"env_id":"optional"}POST /stepwith action payload:{"action":"flag","confidence":0.78}GET /stateto read the current observation without advancingGET /healthzfor a basic service checkGET /healthfor the OpenEnv runtime validatorGET /metadatafor environment metadataGET /schemafor action, observation, and state schemasPOST /mcpfor the JSON-RPC compatibility check used by the OpenEnv runtime validator- Optional multi-session routing via
X-Env-Idheader (orenv_idin request body/query) - Use
env_id: "new"on/resetto request a generated session id returned inX-Env-Id
These endpoints return standard OpenEnv-style JSON responses and allow the official submission validator to ping the Space directly.
π What Makes This Submission Stand Out
π License
MIT β see LICENSE.
Built for the Meta Γ Hugging Face Γ PyTorch OpenEnv Hackathon.
