-
Notifications
You must be signed in to change notification settings - Fork 417
Add LaTeX OCR: multimodal env on the new Task API + dataset streaming #1003
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
adithya-s-k
wants to merge
19
commits into
huggingface:main
Choose a base branch
from
adithya-s-k:add-latex-ocr-multimodal-streaming-env
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
568a59e
Add LaTeX OCR: multimodal env on the new Task API + dataset streaming
adithya-s-k a7b1d77
Make server concurrency configurable (LATEX_OCR_MAX_SESSIONS, default…
adithya-s-k a9fa176
Composable reward rubric: add structural-validity + length/format com…
adithya-s-k 4b57f66
Fix Gradio UI reward display: read top-level reward + show component …
adithya-s-k 367f336
Revert to simple dense reward for training stability
adithya-s-k e870fbe
Add GRPO + LaTeX-OCR tutorial notebook to examples
adithya-s-k b392406
grpo_latex_ocr tutorial: verbose cell output + example previews in eval
adithya-s-k 12f45a2
grpo_latex_ocr tutorial: USE_TRACKIO toggle, live eval avg, 500 train…
adithya-s-k 483f494
grpo_latex_ocr tutorial: fast eval (KV cache on, gradient checkpointi…
adithya-s-k 141f8c2
grpo_latex_ocr tutorial: pin local env to materialize mode + cap rows…
adithya-s-k 0bf2592
latex_ocr_env: address review feedback + add docs stub
adithya-s-k ed46f0d
latex_ocr_env: more review fixes (split-total 0 vs unknown, strict sp…
adithya-s-k a81bd87
docs: add latex_ocr env to the toctree
adithya-s-k 4d1730f
grpo_latex_ocr tutorial: install gradio in the local-serve cell (env …
adithya-s-k e4f7ef4
Reward: raise exact-match weight to 0.4 (edit 0.6 / exact 0.4), env-v…
adithya-s-k 3154ef9
latex_ocr: length guard against whitespace-padding reward hack
adithya-s-k 238b36e
grpo_latex_ocr example: add Results section with 4-model training curves
adithya-s-k 8ba41aa
Merge branch 'main' into add-latex-ocr-multimodal-streaming-env
adithya-s-k 503337f
Merge branch 'main' into add-latex-ocr-multimodal-streaming-env
sergiopaniego File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| <!-- openenv-source: latex_ocr_env --> | ||
| # LaTeX OCR Environment | ||
|
|
||
| A dataset-backed, single-step (bandit) RL environment for **image → LaTeX** | ||
| transcription, served through OpenEnv. | ||
|
|
||
| - **Task**: the agent is shown an image of a math/text expression and must | ||
| return its LaTeX source. | ||
| - **Dataset**: tasks are served from a Hugging Face dataset (default | ||
| [`unsloth/LaTeX_OCR`](https://huggingface.co/datasets/unsloth/LaTeX_OCR): | ||
| `image` + `text` columns, `train`/`test` splits) via the OpenEnv **Task API**. | ||
| - **Reward**: `[(1 - exact_weight) * (1 - CER) + exact_weight * exact_match] * length_factor`, | ||
| where `CER` is the normalized character edit distance over whitespace-stripped | ||
| LaTeX. With the default `exact_weight=0.4` (tunable via `LATEX_OCR_EXACT_WEIGHT`), | ||
| partial answers score in `[0, 0.6]`; only an exact match reaches `1.0`. | ||
| Computed **server-side** against the hidden ground truth — the agent never | ||
| sees the target on `reset`. Dense and smooth, for stable RL training. | ||
| - **Length guard**: because the score is whitespace-insensitive, a policy could | ||
| otherwise emit the correct answer followed by unlimited whitespace and still | ||
| score `1.0` — a reward hack that, in RL, shows up as completions drifting to | ||
| the generation-length cap. `length_factor` decays the reward once the *raw* | ||
| prediction grows past `LATEX_OCR_OVERLONG_RATIO`× the target length (default | ||
| `4.0`, floor `LATEX_OCR_OVERLONG_FLOOR=80` chars); normal LaTeX spacing is well | ||
| within the allowance. Set `LATEX_OCR_OVERLONG_RATIO=0` to disable. The rubric | ||
| also strips code fences / `$`-delimiters from raw completions itself. | ||
|
|
||
| ## Episode | ||
|
|
||
| ``` | ||
| reset(split="test", index=0) -> observation { image_base64, prompt, ... } # target hidden | ||
| step(LatexOCRAction(latex=…)) -> reward, done=True { target_latex, exact_match, char_error_rate } | ||
| ``` | ||
|
|
||
| ## Task API | ||
|
|
||
| | Endpoint | Purpose | | ||
| |---|---| | ||
| | `GET /latex_ocr_env/splits` | list splits (`train`, `test`) | | ||
| | `POST /latex_ocr_env/num_tasks` | row count for a split | | ||
| | `POST /latex_ocr_env/tasks` | all task specs for a split | | ||
| | `POST /latex_ocr_env/task` | one task by `{split, index}` | | ||
| | `POST /latex_ocr_env/task_range` | slice `{split, start, stop}` | | ||
|
|
||
| Client helpers: `env.list_splits()`, `env.num_tasks(split)`, | ||
| `env.get_task(split, index)`, `env.get_task_range(split, start, stop)`. | ||
|
|
||
| ## Run locally | ||
|
|
||
| ```bash | ||
| # From the repo root. LATEX_OCR_SPLITS=test avoids the 380MB train download. | ||
| PYTHONPATH=src:envs/latex_ocr_env LATEX_OCR_SPLITS=test \ | ||
| uv run --with datasets --with pillow --with fastapi --with uvicorn --with websockets \ | ||
| uvicorn server.app:app --host 0.0.0.0 --port 8000 | ||
| ``` | ||
|
|
||
| Then drive it (needs `HF_TOKEN` for the real VLM policy): | ||
|
|
||
| ```bash | ||
| HF_TOKEN=hf_xxx PYTHONPATH=src \ | ||
| uv run --with datasets --with pillow --with requests --with websockets --with openai \ | ||
| python envs/latex_ocr_env/validate.py --split test --num 3 \ | ||
| --model Qwen/Qwen2.5-VL-7B-Instruct | ||
| ``` | ||
|
|
||
| ## Configuration (env vars) | ||
|
|
||
| | Var | Default | Meaning | | ||
| |---|---|---| | ||
| | `LATEX_OCR_DATASET` | `unsloth/LaTeX_OCR` | source dataset | | ||
| | `LATEX_OCR_IMAGE_COLUMN` | `image` | image column | | ||
| | `LATEX_OCR_TEXT_COLUMN` | `text` | ground-truth LaTeX column | | ||
| | `LATEX_OCR_SPLITS` | `train,test` | splits to expose | | ||
| | `LATEX_OCR_MAX_ROWS` | — | cap rows per split (dev) | | ||
| | `LATEX_OCR_EXACT_WEIGHT` | `0.4` | exact-match share of the reward | | ||
| | `LATEX_OCR_OVERLONG_RATIO` | `4.0` | raw length allowed as a multiple of the target before the reward decays (`0` disables the length guard) | | ||
| | `LATEX_OCR_OVERLONG_FLOOR` | `80` | minimum allowed raw length (chars) for short targets | | ||
|
|
||
| Swap in any `(image, latex)` dataset by pointing `LATEX_OCR_DATASET` at it (and | ||
| the column vars if they differ). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| --- | ||
| title: LaTeX OCR Env | ||
| emoji: 📐 | ||
| colorFrom: blue | ||
| colorTo: indigo | ||
| sdk: docker | ||
| app_port: 8000 | ||
| pinned: false | ||
| --- | ||
|
|
||
| # LaTeX OCR Environment | ||
|
|
||
| A dataset-backed, single-step (bandit) RL environment for **image → LaTeX** | ||
| transcription, served through OpenEnv. | ||
|
|
||
| - **Task**: the agent is shown an image of a math/text expression and must | ||
| return its LaTeX source. | ||
| - **Dataset**: tasks are served from a Hugging Face dataset (default | ||
| [`unsloth/LaTeX_OCR`](https://huggingface.co/datasets/unsloth/LaTeX_OCR): | ||
| `image` + `text` columns, `train`/`test` splits) via the OpenEnv **Task API**. | ||
| - **Reward**: `[(1 - exact_weight) * (1 - CER) + exact_weight * exact_match] * length_factor`, | ||
| where `CER` is the normalized character edit distance over whitespace-stripped | ||
| LaTeX. With the default `exact_weight=0.4` (tunable via `LATEX_OCR_EXACT_WEIGHT`), | ||
| partial answers score in `[0, 0.6]`; only an exact match reaches `1.0`. | ||
| Computed **server-side** against the hidden ground truth — the agent never | ||
| sees the target on `reset`. Dense and smooth, for stable RL training. | ||
| - **Length guard**: because the score is whitespace-insensitive, a policy could | ||
| otherwise emit the correct answer followed by unlimited whitespace and still | ||
| score `1.0` — a reward hack that, in RL, shows up as completions drifting to | ||
| the generation-length cap. `length_factor` decays the reward once the *raw* | ||
| prediction grows past `LATEX_OCR_OVERLONG_RATIO`× the target length (default | ||
| `4.0`, floor `LATEX_OCR_OVERLONG_FLOOR=80` chars); normal LaTeX spacing is well | ||
| within the allowance. Set `LATEX_OCR_OVERLONG_RATIO=0` to disable. The rubric | ||
| also strips code fences / `$`-delimiters from raw completions itself. | ||
|
|
||
| ## Episode | ||
|
|
||
| ``` | ||
| reset(split="test", index=0) -> observation { image_base64, prompt, ... } # target hidden | ||
| step(LatexOCRAction(latex=…)) -> reward, done=True { target_latex, exact_match, char_error_rate } | ||
| ``` | ||
|
|
||
| ## Task API | ||
|
|
||
| | Endpoint | Purpose | | ||
| |---|---| | ||
| | `GET /latex_ocr_env/splits` | list splits (`train`, `test`) | | ||
| | `POST /latex_ocr_env/num_tasks` | row count for a split | | ||
| | `POST /latex_ocr_env/tasks` | all task specs for a split | | ||
| | `POST /latex_ocr_env/task` | one task by `{split, index}` | | ||
| | `POST /latex_ocr_env/task_range` | slice `{split, start, stop}` | | ||
|
|
||
| Client helpers: `env.list_splits()`, `env.num_tasks(split)`, | ||
| `env.get_task(split, index)`, `env.get_task_range(split, start, stop)`. | ||
|
|
||
| ## Run locally | ||
|
|
||
| ```bash | ||
| # From the repo root. LATEX_OCR_SPLITS=test avoids the 380MB train download. | ||
| PYTHONPATH=src:envs/latex_ocr_env LATEX_OCR_SPLITS=test \ | ||
| uv run --with datasets --with pillow --with fastapi --with uvicorn --with websockets \ | ||
| uvicorn server.app:app --host 0.0.0.0 --port 8000 | ||
| ``` | ||
|
|
||
| Then drive it (needs `HF_TOKEN` for the real VLM policy): | ||
|
|
||
| ```bash | ||
| HF_TOKEN=hf_xxx PYTHONPATH=src \ | ||
| uv run --with datasets --with pillow --with requests --with websockets --with openai \ | ||
| python envs/latex_ocr_env/validate.py --split test --num 3 \ | ||
| --model Qwen/Qwen2.5-VL-7B-Instruct | ||
| ``` | ||
|
|
||
| ## Configuration (env vars) | ||
|
|
||
| | Var | Default | Meaning | | ||
| |---|---|---| | ||
| | `LATEX_OCR_DATASET` | `unsloth/LaTeX_OCR` | source dataset | | ||
| | `LATEX_OCR_IMAGE_COLUMN` | `image` | image column | | ||
| | `LATEX_OCR_TEXT_COLUMN` | `text` | ground-truth LaTeX column | | ||
| | `LATEX_OCR_SPLITS` | `train,test` | splits to expose | | ||
| | `LATEX_OCR_MAX_ROWS` | — | cap rows per split (dev) | | ||
| | `LATEX_OCR_EXACT_WEIGHT` | `0.4` | exact-match share of the reward | | ||
| | `LATEX_OCR_OVERLONG_RATIO` | `4.0` | raw length allowed as a multiple of the target before the reward decays (`0` disables the length guard) | | ||
| | `LATEX_OCR_OVERLONG_FLOOR` | `80` | minimum allowed raw length (chars) for short targets | | ||
|
|
||
| Swap in any `(image, latex)` dataset by pointing `LATEX_OCR_DATASET` at it (and | ||
| the column vars if they differ). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # Copyright (c) Meta Platforms, Inc. and affiliates. | ||
| # All rights reserved. | ||
| # | ||
| # This source code is licensed under the BSD-style license found in the | ||
| # LICENSE file in the root directory of this source tree. | ||
|
|
||
| """LaTeX OCR Environment - dataset-backed, single-step RL for image→LaTeX.""" | ||
|
|
||
| from .client import LatexOCREnv | ||
| from .models import LatexOCRAction, LatexOCRObservation | ||
|
|
||
| __all__ = ["LatexOCRAction", "LatexOCRObservation", "LatexOCREnv"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| # Copyright (c) Meta Platforms, Inc. and affiliates. | ||
| # All rights reserved. | ||
| # | ||
| # This source code is licensed under the BSD-style license found in the | ||
| # LICENSE file in the root directory of this source tree. | ||
|
|
||
| """ | ||
| LaTeX OCR Environment client. | ||
|
|
||
| Gym-style client (reset/step over WebSocket) plus thin HTTP helpers for the | ||
| Task API so a trainer can enumerate and select dataset tasks. | ||
|
|
||
| Example: | ||
| >>> with LatexOCREnv(base_url="http://localhost:8000") as env: | ||
| ... print(env.list_splits()) # ["train", "test"] | ||
| ... print(env.num_tasks("test")) # 7595 | ||
| ... result = env.reset(split="test", index=0) | ||
| ... img = result.observation.image_base64 | ||
| ... # ... run a VLM to produce `latex` ... | ||
| ... result = env.step(LatexOCRAction(latex=latex)) | ||
| ... print(result.reward, result.observation.target_latex) | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, Optional | ||
| from urllib.parse import urljoin | ||
|
|
||
| import requests | ||
|
|
||
| try: | ||
| from openenv.core.client_types import StepResult | ||
| from openenv.core.env_client import EnvClient | ||
| from openenv.core.env_server.types import State | ||
| except ImportError: | ||
| from core.client_types import StepResult | ||
| from core.env_client import EnvClient | ||
| from core.env_server.types import State | ||
|
|
||
| from .models import LatexOCRAction, LatexOCRObservation | ||
|
|
||
| ENV_NAME = "latex_ocr_env" | ||
|
|
||
|
|
||
| class LatexOCREnv(EnvClient[LatexOCRAction, LatexOCRObservation, State]): | ||
| """Client for the LaTeX OCR environment.""" | ||
|
|
||
| def reset( | ||
| self, | ||
| split: str = "train", | ||
| index: Optional[int] = None, | ||
| seed: Optional[int] = None, | ||
| **kwargs: Any, | ||
| ) -> StepResult[LatexOCRObservation]: | ||
| """Reset to a specific dataset task (or a random one if ``index`` is None).""" | ||
| payload: dict[str, Any] = {"split": split} | ||
| if index is not None: | ||
| payload["index"] = index | ||
| if seed is not None: | ||
| payload["seed"] = seed | ||
| payload.update(kwargs) | ||
| return super().reset(**payload) | ||
|
|
||
| # --- Gym-style (de)serialization required by EnvClient --- | ||
| def _step_payload(self, action: LatexOCRAction) -> dict[str, Any]: | ||
| return action.model_dump() | ||
|
|
||
| def _parse_result(self, data: dict[str, Any]) -> StepResult[LatexOCRObservation]: | ||
| obs_data = dict(data.get("observation", data)) | ||
| # Core serialization lifts reward/done to the top level and strips them from | ||
| # the observation payload; merge them back so observation.reward/.done match | ||
| # StepResult (consistent with other env clients). | ||
| reward = data.get("reward", obs_data.get("reward")) | ||
| done = data.get("done", obs_data.get("done")) | ||
| obs_data["reward"] = reward | ||
| obs_data["done"] = done | ||
| obs = LatexOCRObservation(**obs_data) | ||
| base = dict(observation=obs, reward=reward, done=done) | ||
| # Newer core's StepResult carries `info`; older core does not. | ||
| try: | ||
| return StepResult(**base, info=data.get("info", {})) | ||
| except TypeError: | ||
| return StepResult(**base) | ||
|
|
||
| def _parse_state(self, data: dict[str, Any]) -> State: | ||
| return State(**data) | ||
|
|
||
| # ------------------------------------------------------------------ # | ||
| # Task API (HTTP) # | ||
| # ------------------------------------------------------------------ # | ||
| def _http_base(self) -> str: | ||
| # Newer core exposes ``_base_url`` (http); older core stores only | ||
| # ``_ws_url`` (ws://host/ws). Derive an http base that works for both. | ||
| base = getattr(self, "_base_url", None) | ||
| if not base: | ||
| ws = getattr(self, "_ws_url", None) | ||
| if not ws: | ||
| raise RuntimeError("Task API requires an HTTP base URL") | ||
| base = ws[:-3] if ws.endswith("/ws") else ws | ||
| base = base.replace("wss://", "https://").replace("ws://", "http://") | ||
| return base if base.endswith("/") else base + "/" | ||
|
|
||
| def list_splits(self) -> list[str]: | ||
| resp = requests.get( | ||
| urljoin(self._http_base(), f"{ENV_NAME}/splits"), timeout=30 | ||
| ) | ||
| resp.raise_for_status() | ||
| return [s["name"] for s in resp.json()] | ||
|
|
||
| def num_tasks(self, split: str) -> int: | ||
| resp = requests.post( | ||
| urljoin(self._http_base(), f"{ENV_NAME}/num_tasks"), | ||
| json={"split": split}, | ||
| timeout=60, | ||
| ) | ||
| resp.raise_for_status() | ||
| return int(resp.json()["num_tasks"]) | ||
|
|
||
| def get_task(self, split: str, index: int) -> dict[str, Any]: | ||
| resp = requests.post( | ||
| urljoin(self._http_base(), f"{ENV_NAME}/task"), | ||
| json={"split": split, "index": index}, | ||
| timeout=60, | ||
| ) | ||
| resp.raise_for_status() | ||
| return resp.json()["task"] | ||
|
|
||
| def get_task_range( | ||
| self, split: str, start: int | None = None, stop: int | None = None | ||
| ) -> list[dict[str, Any]]: | ||
| resp = requests.post( | ||
| urljoin(self._http_base(), f"{ENV_NAME}/task_range"), | ||
| json={"split": split, "start": start, "stop": stop}, | ||
| timeout=120, | ||
| ) | ||
| resp.raise_for_status() | ||
| return resp.json()["tasks"] | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Client drops StepResult metadata
Low Severity
_parse_resulttries to constructStepResultwithinfo=, but current core’sStepResultexposesmetadata, notinfo. TheTypeErrorfallback omits it, so top-level step metadata from the server is always dropped even though sibling env clients passmetadata=successfully.Reviewed by Cursor Bugbot for commit 8ba41aa. Configure here.