diff --git a/AGENTS_DEV.md b/AGENTS_DEV.md index 00fd0ffe553..321006fe7e1 100644 --- a/AGENTS_DEV.md +++ b/AGENTS_DEV.md @@ -39,20 +39,21 @@ TensorBored is a fork of [TensorBoard](https://github.com/tensorflow/tensorboard The key features added on top of TensorBoard are: -| Feature | Issue | PR(s) | -|---------|-------|-------| -| Stable/programmatic run colors | #1 | #12 | -| Upstream sync bot | #2 | #10 | -| Log/symlog x-axis scales | #3 | #8 | -| Superimposed plots | #4 | #9, #19 | -| Dashboard profiles (localStorage) | #5 | #12 | -| CI wheel builds | #6 | #7, #11, #15 | -| Metric descriptions | #20 | #23 | -| Pinned card reordering | #21 | #22 | -| Shift-select runs | #25 | — | -| PR preview deployments | — | #24 | -| HuggingFace Spaces demo | — | #16, #27, #28, #29, #30 | -| Default axis scales in profiles | #32 | — | +| Feature | Issue | PR(s) | +| ------------------------------------ | ----- | ----------------------- | +| Stable/programmatic run colors | #1 | #12 | +| Upstream sync bot | #2 | #10 | +| Log/symlog x-axis scales | #3 | #8 | +| Superimposed plots | #4 | #9, #19 | +| Dashboard profiles (localStorage) | #5 | #12 | +| CI wheel builds | #6 | #7, #11, #15 | +| Metric descriptions | #20 | #23 | +| Pinned card reordering | #21 | #22 | +| Shift-select runs | #25 | — | +| PR preview deployments | — | #24 | +| HuggingFace Spaces demo | — | #16, #27, #28, #29, #30 | +| Default axis scales in profiles | #32 | — | +| Configurable symlog linear threshold | #34 | — | --- @@ -145,11 +146,11 @@ The frontend uses Angular with NgRx for state management. The pattern is: ### Key State Slices -| Slice | Location | Contents | -|-------|----------|----------| +| Slice | Location | Contents | +| --------- | ----------------------- | -------------------------------------------------------------------------------------- | | `metrics` | `webapp/metrics/store/` | Card data, pinned cards, superimposed cards, tag filter, smoothing, x/y scale settings | -| `runs` | `webapp/runs/store/` | Run metadata, selection state (visible/hidden), color overrides, group colors | -| `profile` | `webapp/profile/store/` | Available profiles, active profile, unsaved changes flag | +| `runs` | `webapp/runs/store/` | Run metadata, selection state (visible/hidden), color overrides, group colors | +| `profile` | `webapp/profile/store/` | Available profiles, active profile, unsaved changes flag | ### Component Patterns @@ -160,24 +161,24 @@ The frontend uses Angular with NgRx for state management. The pattern is: ### Key Feature Files -| Feature | Key Files | -|---------|-----------| -| **Superimposed cards** | `webapp/metrics/views/card_renderer/superimposed_card_container.ts`, `superimposed_card_component.ts`, `superimposed_card_component.ng.html` | -| **Superimposed state** | `webapp/metrics/store/metrics_reducers.ts` (actions: `superimposedCardCreated`, `superimposedCardTagAdded`, `superimposedCardTagRemoved`, `superimposedCardDeleted`) | -| **Pinned cards** | `webapp/metrics/store/metrics_reducers.ts` (pin/unpin reducers, reorder action `metricsPinnedCardsReordered`) | -| **Pinned card reorder UI** | `webapp/metrics/views/main_view/` (CDK Drag&Drop, arrow buttons on card headers) | -| **Run selection** | `webapp/runs/store/runs_reducers.ts` (single toggle, range toggle, page toggle) | -| **Shift-select runs** | `webapp/runs/views/runs_table/runs_data_table.ts` (`selectionClick` with shift key, `lastClickedIndex`), `webapp/runs/actions/runs_actions.ts` (`runRangeSelectionToggled`) | -| **Run colors** | `webapp/runs/store/runs_reducers.ts` (hash-based fallback, profile overrides) | -| **Profile system** | `webapp/profile/` directory (types, data_source, store, effects, views) | -| **Profile menu** | `webapp/profile/views/profile_menu_component.ts` (mat-icon-button, bookmark icon, unsaved dot indicator) | -| **Tag filter** | `webapp/metrics/views/main_view/filter_input_*` | -| **Tag filter persistence** | `webapp/metrics/effects/index.ts` (`persistTagFilter$`, `loadTagFilterFromStorage$`) | -| **Scale types** | `webapp/widgets/line_chart_v2/lib/scale.ts` (LINEAR, LOG10, SYMLOG10), `webapp/widgets/line_chart_v2/lib/scale_types.ts` | -| **Axis scales** | `webapp/metrics/store/metrics_types.ts` (yAxisScale, xAxisScale in MetricsSettings), `webapp/profile/types.ts` (AxisScaleName, conversion utils) | -| **Legacy symlog** | `components/vz_line_chart2/symlog-scale.ts` (Plottable-based `SymLogScale`) | -| **Metric descriptions** | `webapp/metrics/views/utils.ts` (`htmlToText`, `buildTagTooltip`), card components fetch `tagDescription` | -| **Card scale cycling** | Scalar cards and superimposed cards cycle `LINEAR → LOG10 → SYMLOG10 → LINEAR` on click for both X and Y axes (X-axis only for STEP/RELATIVE) | +| Feature | Key Files | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Superimposed cards** | `webapp/metrics/views/card_renderer/superimposed_card_container.ts`, `superimposed_card_component.ts`, `superimposed_card_component.ng.html` | +| **Superimposed state** | `webapp/metrics/store/metrics_reducers.ts` (actions: `superimposedCardCreated`, `superimposedCardTagAdded`, `superimposedCardTagRemoved`, `superimposedCardDeleted`) | +| **Pinned cards** | `webapp/metrics/store/metrics_reducers.ts` (pin/unpin reducers, reorder action `metricsPinnedCardsReordered`) | +| **Pinned card reorder UI** | `webapp/metrics/views/main_view/` (CDK Drag&Drop, arrow buttons on card headers) | +| **Run selection** | `webapp/runs/store/runs_reducers.ts` (single toggle, range toggle, page toggle) | +| **Shift-select runs** | `webapp/runs/views/runs_table/runs_data_table.ts` (`selectionClick` with shift key, `lastClickedIndex`), `webapp/runs/actions/runs_actions.ts` (`runRangeSelectionToggled`) | +| **Run colors** | `webapp/runs/store/runs_reducers.ts` (hash-based fallback, profile overrides) | +| **Profile system** | `webapp/profile/` directory (types, data_source, store, effects, views) | +| **Profile menu** | `webapp/profile/views/profile_menu_component.ts` (mat-icon-button, bookmark icon, unsaved dot indicator) | +| **Tag filter** | `webapp/metrics/views/main_view/filter_input_*` | +| **Tag filter persistence** | `webapp/metrics/effects/index.ts` (`persistTagFilter$`, `loadTagFilterFromStorage$`) | +| **Scale types** | `webapp/widgets/line_chart_v2/lib/scale.ts` (LINEAR, LOG10, SYMLOG10 with configurable linearThreshold), `webapp/widgets/line_chart_v2/lib/scale_types.ts` | +| **Axis scales** | `webapp/metrics/store/metrics_types.ts` (yAxisScale, xAxisScale in MetricsSettings), `webapp/profile/types.ts` (AxisScaleName, conversion utils) | +| **Legacy symlog** | `components/vz_line_chart2/symlog-scale.ts` (Plottable-based `SymLogScale`) | +| **Metric descriptions** | `webapp/metrics/views/utils.ts` (`htmlToText`, `buildTagTooltip`), card components fetch `tagDescription` | +| **Card scale cycling** | Scalar cards and superimposed cards cycle `LINEAR → LOG10 → SYMLOG10 → LINEAR` on click for both X and Y axes (X-axis only for STEP/RELATIVE) | --- @@ -186,6 +187,7 @@ The frontend uses Angular with NgRx for state management. The pattern is: ### Plugin System Each backend plugin provides: + - Data loading from tfevents files - HTTP endpoints consumed by the frontend - (Optionally) summary writing utilities @@ -199,6 +201,7 @@ Plugins are registered via entry points in `pyproject.toml` or discovered by the Python API for training scripts to configure default dashboard profiles. Writes `/.tensorboard/default_profile.json`. Key functions: + - `create_profile(...)` — builds a profile dict - `write_profile(logdir, profile)` — writes to disk - `set_default_profile(logdir, ...)` — convenience: create + write in one call @@ -214,6 +217,7 @@ Profile data schema version is tracked via `PROFILE_VERSION = 1`. Generates perceptually uniform colors using the OKLCH color space (OKLCH → OKLAB → Linear sRGB → sRGB → Hex). Key API: + - `sample_colors(n, lightness, chroma, hue_start, hue_range)` — n evenly-spaced colors - `sample_colors_varied(n)` — varied lightness/chroma for >8 colors - `ColorMap(n)` — callable class: `cm(i)` returns the i-th color @@ -224,6 +228,7 @@ Key API: ### Core Plugin Endpoints The core plugin (`core_plugin.py`) exposes a `/data/profile` endpoint: + - **GET**: Returns the default profile JSON from `/.tensorboard/default_profile.json` - The frontend fetches this on navigation and auto-applies it (if present and no user profile is active) @@ -235,18 +240,19 @@ The metrics plugin (`metrics_plugin.py`) merges `metric_descriptions` from the d The frontend persists state to browser localStorage. This is the core mechanism for TensorBored's "persistent settings" feature. -| Key | Purpose | Format | Persisted By | -|-----|---------|--------|--------------| -| `_tb_profile.*` | Saved profile data | JSON `ProfileData` | Profile effects | -| `_tb_profiles_index` | List of profile names | JSON string array | Profile effects | -| `_tb_active_profile` | Currently active profile name | Plain string | Profile effects | -| `_tb_run_selection.v1` | Run visibility states | `{version: 1, runSelection: [[id, bool], ...]}` | Runs effects | -| `_tb_run_colors.v1` | Color overrides | `{version: 1, runColorOverrides: [...], groupKeyToColorId: [...]}` | Runs effects | -| `_tb_tag_filter.v1` | Tag filter regex | `{value: string, timestamp: number}` | Metrics effects | -| `_tb_axis_scales.v1` | Axis scales | `{version: 1, yAxisScale?: string, xAxisScale?: string}` | Metrics effects | -| `tb-saved-pins` | Pinned cards | JSON `CardUniqueInfo[]` | Metrics effects | +| Key | Purpose | Format | Persisted By | +| ---------------------- | ----------------------------- | ------------------------------------------------------------------ | --------------- | +| `_tb_profile.*` | Saved profile data | JSON `ProfileData` | Profile effects | +| `_tb_profiles_index` | List of profile names | JSON string array | Profile effects | +| `_tb_active_profile` | Currently active profile name | Plain string | Profile effects | +| `_tb_run_selection.v1` | Run visibility states | `{version: 1, runSelection: [[id, bool], ...]}` | Runs effects | +| `_tb_run_colors.v1` | Color overrides | `{version: 1, runColorOverrides: [...], groupKeyToColorId: [...]}` | Runs effects | +| `_tb_tag_filter.v1` | Tag filter regex | `{value: string, timestamp: number}` | Metrics effects | +| `_tb_axis_scales.v1` | Axis scales | `{version: 1, yAxisScale?: string, xAxisScale?: string}` | Metrics effects | +| `tb-saved-pins` | Pinned cards | JSON `CardUniqueInfo[]` | Metrics effects | Important behaviors: + - When loading run selection from localStorage, if **all** runs would be hidden, the selection is discarded and all runs default to visible. - Tag filter persistence uses timestamps: user-set values override profile defaults. - Pins are synced to localStorage both when saving profiles and when pinning/unpinning cards directly. @@ -306,16 +312,17 @@ bazel test //tensorbored/plugins/... ## CI/CD Workflows -| Workflow | File | Trigger | Purpose | -|----------|------|---------|---------| -| CI | `ci.yml` | Push, PR | Build, test, lint, build wheel (master only), PR preview deploy | -| Wheel Prerelease | `wheel-prerelease.yml` | Push to master | Build RC wheel, publish to PyPI, trigger demo deploy | -| Deploy Demo | `deploy-demo.yml` | Called by wheel-prerelease | Deploy to HuggingFace Spaces (`demonstrandum-tensorbored-sample`) | -| PR Preview | Part of `ci.yml` | PR open/sync | Deploy PR preview to `Demonstrandum/tensorbored-pr-{N}` HF Space | -| Nightly Release | `nightly-release.yml` | Scheduled | Nightly wheel build | -| Upstream Sync | `upstream-sync.yml` | Daily 06:00 UTC | Merge latest changes from `tensorflow/tensorboard` | +| Workflow | File | Trigger | Purpose | +| ---------------- | ---------------------- | -------------------------- | ----------------------------------------------------------------- | +| CI | `ci.yml` | Push, PR | Build, test, lint, build wheel (master only), PR preview deploy | +| Wheel Prerelease | `wheel-prerelease.yml` | Push to master | Build RC wheel, publish to PyPI, trigger demo deploy | +| Deploy Demo | `deploy-demo.yml` | Called by wheel-prerelease | Deploy to HuggingFace Spaces (`demonstrandum-tensorbored-sample`) | +| PR Preview | Part of `ci.yml` | PR open/sync | Deploy PR preview to `Demonstrandum/tensorbored-pr-{N}` HF Space | +| Nightly Release | `nightly-release.yml` | Scheduled | Nightly wheel build | +| Upstream Sync | `upstream-sync.yml` | Daily 06:00 UTC | Merge latest changes from `tensorflow/tensorboard` | Key CI details: + - Wheel artifacts are named `tensorbored-wheel_py*` (not `tensorbored-nightly_py*`) - The HF Spaces deploy uses a `.build-version` file to bust Docker layer caches - PR preview spaces are auto-deleted when the PR is closed/merged @@ -383,7 +390,7 @@ Key CI details: ## Feature History and Context -This section provides context on *why* features were built the way they were, based on the issue and PR history. +This section provides context on _why_ features were built the way they were, based on the issue and PR history. ### Stable Run Colors (#1) @@ -392,6 +399,7 @@ TensorBoard assigned random colors to runs, which changed on every page refresh. ### Dashboard Profiles (#5, #12) The single biggest architectural addition. TensorBoard stored all dashboard state in URL parameters, hitting browser URL length limits with many pins. TensorBored moved everything to localStorage-based profiles: + - Save/load/delete/export/import profiles - Backend can provide a `default_profile.json` that auto-applies on first load - Profiles store: pinned cards, run colors, group colors, superimposed cards, run selection, tag filter, smoothing, groupBy, metric descriptions @@ -401,6 +409,7 @@ The single biggest architectural addition. TensorBoard stored all dashboard stat ### Superimposed Plots (#4, #9, #19) Users wanted to compare metrics on a single chart. The implementation adds a new card type (`SuperimposedCard`) to the existing card system: + - State: `SuperimposedCardId`, `SuperimposedCardMetadata` with ordered tag lists - Scalar cards have "Add to superimposed plot" in their overflow menu with a submenu to create new or add to existing - Titles auto-update as `tag1 + tag2 + ...` @@ -408,9 +417,17 @@ Users wanted to compare metrics on a single chart. The implementation adds a new - Pan/zoom is wired up via viewBox - Cards are persisted in profiles and localStorage -### Log/Symlog Scales (#3, #8) +### Log/Symlog Scales (#3, #8, #34) + +Added `SYMLOG10` to the `ScaleType` enum. The symmetric log scale uses the log-modulus transformation: `sign(x) * log10(|x|/c + 1)`, where `c` is the **linear threshold** parameter. This handles zero and negative values gracefully. Both X and Y axes cycle `LINEAR → LOG10 → SYMLOG10`. X-axis scale is only available for STEP and RELATIVE axis types (not WALL_TIME). A legacy Plottable-based `SymLogScale` was also added for `vz_line_chart2`. + +The linear threshold `c` (default 1) controls where the scale transitions from linear to logarithmic behavior: -Added `SYMLOG10` to the `ScaleType` enum. The symmetric log scale uses the log-modulus transformation: `sign(x) * log10(|x| + 1)`. This handles zero and negative values gracefully. Both X and Y axes cycle `LINEAR → LOG10 → SYMLOG10`. X-axis scale is only available for STEP and RELATIVE axis types (not WALL_TIME). A legacy Plottable-based `SymLogScale` was also added for `vz_line_chart2`. +- `c = 1`: linear for |x| < 1 (default, original behavior) +- `c = 10`: linear for |x| < 10 (good for data with large values near zero) +- `c = 0.01`: linear for |x| < 0.01 (good for very small-scale data) + +The threshold is configurable via the Settings pane under "Scalars → Symlog Linear Threshold" and is persisted in profiles and backend settings. The Python `profile_writer` also accepts `symlog_linear_threshold` when creating profiles. ### Pinned Card Reordering (#21, #22) @@ -423,6 +440,7 @@ Long-form descriptions for metrics, set via `metric_descriptions` in the profile ### Shift-Select Runs (#25) Users wanted to select a whole range of runs at once using the classic shift+click start+end shortcut. The implementation adds shift-click range selection to the runs data table: + - A `lastClickedIndex` is tracked in `RunsDataTable` as component state. Normal clicks set the anchor index and emit the existing single-toggle event. - Shift+click computes the range `[min(anchor, clicked), max(anchor, clicked)]`, collects all run IDs in that range from the displayed `data` array, and emits a new `onRangeSelectionToggle` event with the run IDs and target selected state (toggled from the clicked run's current state). - A new NgRx action `runRangeSelectionToggled({runIds, selected})` sets all specified runs to the given state. @@ -450,9 +468,9 @@ In scalar card and superimposed card components, the profile's scale is applied ## Open Issues and Future Work -| Issue | Status | Description | -|-------|--------|-------------| -| #25 | Implemented | Shift-select runs to toggle a range (shift+click to select a contiguous range of runs) | +| Issue | Status | Description | +| ----- | ----------- | -------------------------------------------------------------------------------------- | +| #25 | Implemented | Shift-select runs to toggle a range (shift+click to select a contiguous range of runs) | --- @@ -469,6 +487,7 @@ Check the browser DevTools Network tab. TensorBored should only make requests to ### Empty Charts If charts appear blank: + 1. Check if time series data exists in state (Redux DevTools → `metrics` slice) 2. Verify run selection — are runs set to visible? (`runs` slice → `selectionState`) 3. Check card visibility — intersection observer may not have triggered diff --git a/tensorbored/components/vz_line_chart2/symlog-scale.ts b/tensorbored/components/vz_line_chart2/symlog-scale.ts index 76ac4eece78..1ea356f3db1 100644 --- a/tensorbored/components/vz_line_chart2/symlog-scale.ts +++ b/tensorbored/components/vz_line_chart2/symlog-scale.ts @@ -17,18 +17,22 @@ import * as Plottable from 'plottable'; import {TfScale} from './tf-scale'; /** - * Symmetric log transformation: sign(x) * log10(|x| + 1) + * Symmetric log transformation: sign(x) * log10(|x|/c + 1) * This handles zero and negative values gracefully. + * @param x Input value + * @param c Linear threshold: the region |x| < c is approximately linear (default 1) */ -function symlog(x: number): number { - return Math.sign(x) * Math.log10(Math.abs(x) + 1); +function symlog(x: number, c: number = 1): number { + return Math.sign(x) * Math.log10(Math.abs(x) / c + 1); } /** - * Inverse of symmetric log: sign(y) * (10^|y| - 1) + * Inverse of symmetric log: sign(y) * c * (10^|y| - 1) + * @param y Transformed value + * @param c Linear threshold (must match the c used in symlog) */ -function symexp(y: number): number { - return Math.sign(y) * (Math.pow(10, Math.abs(y)) - 1); +function symexp(y: number, c: number = 1): number { + return Math.sign(y) * c * (Math.pow(10, Math.abs(y)) - 1); } /** diff --git a/tensorbored/plugins/core/profile_writer.py b/tensorbored/plugins/core/profile_writer.py index 47c84915637..eb4d25bb62e 100644 --- a/tensorbored/plugins/core/profile_writer.py +++ b/tensorbored/plugins/core/profile_writer.py @@ -193,10 +193,12 @@ def create_profile( tag_filter: str = "", run_filter: str = "", smoothing: float = 0.6, + symlog_linear_threshold: float = 1.0, group_by: GroupByConfig | None = None, y_axis_scale: AxisScale | None = None, x_axis_scale: AxisScale | None = None, tag_axis_scales: dict[str, TagAxisScale] | None = None, + tag_symlog_linear_thresholds: dict[str, float] | None = None, ) -> SerializedProfile: """Create a TensorBoard profile dictionary. @@ -214,10 +216,14 @@ def create_profile( tag_filter: Regex pattern to filter tags. run_filter: Regex pattern to filter runs. smoothing: Scalar smoothing value (0.0 to 0.999). + symlog_linear_threshold: Linear threshold for the symlog scale. + Controls the width of the linear region near zero. Default 1.0. group_by: Run-grouping configuration. y_axis_scale: Global Y-axis scale for scalar plots. x_axis_scale: Global X-axis scale for scalar plots (STEP/RELATIVE only). + tag_symlog_linear_thresholds: Per-tag symlog linear threshold + overrides. Example: ``{"train/loss": 10.0}`` tag_axis_scales: Per-tag axis scale overrides. Example:: {"train/loss": {"y": "log10"}} @@ -289,6 +295,10 @@ def create_profile( data["xAxisScale"] = x_axis_scale if tag_axis_scales: data["tagAxisScales"] = tag_axis_scales + if symlog_linear_threshold != 1.0: + data["symlogLinearThreshold"] = symlog_linear_threshold + if tag_symlog_linear_thresholds: + data["tagSymlogLinearThresholds"] = tag_symlog_linear_thresholds return SerializedProfile(version=PROFILE_VERSION, data=data) @@ -346,10 +356,12 @@ def set_default_profile( tag_filter: str = "", run_filter: str = "", smoothing: float = 0.6, + symlog_linear_threshold: float = 1.0, group_by: GroupByConfig | None = None, y_axis_scale: AxisScale | None = None, x_axis_scale: AxisScale | None = None, tag_axis_scales: dict[str, TagAxisScale] | None = None, + tag_symlog_linear_thresholds: dict[str, float] | None = None, ) -> str: """Create and write a profile in one call. @@ -371,10 +383,12 @@ def set_default_profile( tag_filter=tag_filter, run_filter=run_filter, smoothing=smoothing, + symlog_linear_threshold=symlog_linear_threshold, group_by=group_by, y_axis_scale=y_axis_scale, x_axis_scale=x_axis_scale, tag_axis_scales=tag_axis_scales, + tag_symlog_linear_thresholds=tag_symlog_linear_thresholds, ) return write_profile(logdir, profile) diff --git a/tensorbored/webapp/metrics/actions/index.ts b/tensorbored/webapp/metrics/actions/index.ts index a90e59261df..71d2c3a98eb 100644 --- a/tensorbored/webapp/metrics/actions/index.ts +++ b/tensorbored/webapp/metrics/actions/index.ts @@ -127,6 +127,11 @@ export const metricsScalarPartitionNonMonotonicXToggled = createAction( '[Metrics] Metrics Setting Partition Non Monotonic X Toggled' ); +export const metricsChangeSymlogLinearThreshold = createAction( + '[Metrics] Metrics Setting Change Symlog Linear Threshold', + props<{symlogLinearThreshold: number}>() +); + export const metricsChangeImageBrightness = createAction( '[Metrics] Metrics Setting Change Image Brightness', props<{brightnessInMilli: number}>() @@ -312,6 +317,11 @@ export const metricsTagXAxisScaleChanged = createAction( props<{tag: string; scaleType: ScaleType}>() ); +export const metricsTagSymlogLinearThresholdChanged = createAction( + '[Metrics] Tag Symlog Linear Threshold Changed', + props<{tag: string; symlogLinearThreshold: number}>() +); + // TODO(jieweiwu): Delete after internal code is updated. export const stepSelectorTimeSelectionChanged = timeSelectionChanged; @@ -406,6 +416,8 @@ export const profileMetricsSettingsApplied = createAction( string, {yAxisScale: ScaleType; xAxisScale: ScaleType} >; + symlogLinearThreshold?: number; + tagSymlogLinearThresholds?: Record; }>() ); diff --git a/tensorbored/webapp/metrics/effects/index.ts b/tensorbored/webapp/metrics/effects/index.ts index b0506492b00..83a18ea46aa 100644 --- a/tensorbored/webapp/metrics/effects/index.ts +++ b/tensorbored/webapp/metrics/effects/index.ts @@ -38,6 +38,8 @@ type StoredAxisScalesV1 = { yAxisScale?: string; xAxisScale?: string; tagAxisScales?: Record; + symlogLinearThreshold?: number; + tagSymlogLinearThresholds?: Record; }; type StoredSuperimposedCard = { @@ -100,6 +102,8 @@ import { getMetricsYAxisScale, getMetricsXAxisScale, getTagAxisScales, + getMetricsSymlogLinearThreshold, + getTagSymlogLinearThresholds, getSuperimposedCardsWithMetadata, } from '../store'; import { @@ -713,50 +717,75 @@ export class MetricsEffects implements OnInitEffects { actions.metricsChangeXAxisScale, actions.metricsTagYAxisScaleChanged, actions.metricsTagXAxisScaleChanged, + actions.metricsChangeSymlogLinearThreshold, + actions.metricsTagSymlogLinearThresholdChanged, actions.profileMetricsSettingsApplied ), debounceTime(200), withLatestFrom( this.store.select(getMetricsYAxisScale), this.store.select(getMetricsXAxisScale), - this.store.select(getTagAxisScales) + this.store.select(getTagAxisScales), + this.store.select(getMetricsSymlogLinearThreshold), + this.store.select(getTagSymlogLinearThresholds) ), - tap(([, yScale, xScale, tagScales]) => { - const tagAxisScalesPayload: Record = - {}; - for (const [tag, scales] of Object.entries(tagScales)) { - const entry: {y?: string; x?: string} = {}; - if (scales.yAxisScale !== ScaleType.LINEAR) { - entry.y = scaleTypeToName(scales.yAxisScale); - } - if (scales.xAxisScale !== ScaleType.LINEAR) { - entry.x = scaleTypeToName(scales.xAxisScale); + tap( + ([ + , + yScale, + xScale, + tagScales, + symlogThreshold, + tagSymlogThresholds, + ]) => { + const tagAxisScalesPayload: Record = + {}; + for (const [tag, scales] of Object.entries(tagScales)) { + const entry: {y?: string; x?: string} = {}; + if (scales.yAxisScale !== ScaleType.LINEAR) { + entry.y = scaleTypeToName(scales.yAxisScale); + } + if (scales.xAxisScale !== ScaleType.LINEAR) { + entry.x = scaleTypeToName(scales.xAxisScale); + } + if (entry.y || entry.x) { + tagAxisScalesPayload[tag] = entry; + } } - if (entry.y || entry.x) { - tagAxisScalesPayload[tag] = entry; + const payload: StoredAxisScalesV1 = { + version: 1, + ...(yScale !== ScaleType.LINEAR + ? {yAxisScale: scaleTypeToName(yScale)} + : undefined), + ...(xScale !== ScaleType.LINEAR + ? {xAxisScale: scaleTypeToName(xScale)} + : undefined), + ...(Object.keys(tagAxisScalesPayload).length > 0 + ? {tagAxisScales: tagAxisScalesPayload} + : undefined), + ...(symlogThreshold !== 1 + ? {symlogLinearThreshold: symlogThreshold} + : undefined), + ...(Object.keys(tagSymlogThresholds).length > 0 + ? {tagSymlogLinearThresholds: tagSymlogThresholds} + : undefined), + }; + if ( + payload.yAxisScale || + payload.xAxisScale || + payload.tagAxisScales || + payload.symlogLinearThreshold || + payload.tagSymlogLinearThresholds + ) { + window.localStorage.setItem( + AXIS_SCALES_STORAGE_KEY, + JSON.stringify(payload) + ); + } else { + window.localStorage.removeItem(AXIS_SCALES_STORAGE_KEY); } } - const payload: StoredAxisScalesV1 = { - version: 1, - ...(yScale !== ScaleType.LINEAR - ? {yAxisScale: scaleTypeToName(yScale)} - : undefined), - ...(xScale !== ScaleType.LINEAR - ? {xAxisScale: scaleTypeToName(xScale)} - : undefined), - ...(Object.keys(tagAxisScalesPayload).length > 0 - ? {tagAxisScales: tagAxisScalesPayload} - : undefined), - }; - if (payload.yAxisScale || payload.xAxisScale || payload.tagAxisScales) { - window.localStorage.setItem( - AXIS_SCALES_STORAGE_KEY, - JSON.stringify(payload) - ); - } else { - window.localStorage.removeItem(AXIS_SCALES_STORAGE_KEY); - } - }) + ) ); // Load axis scales from localStorage on navigation @@ -804,6 +833,30 @@ export class MetricsEffects implements OnInitEffects { } } } + if ( + typeof parsed.symlogLinearThreshold === 'number' && + parsed.symlogLinearThreshold > 0 + ) { + scaleActions.push( + actions.metricsChangeSymlogLinearThreshold({ + symlogLinearThreshold: parsed.symlogLinearThreshold, + }) + ); + } + if (parsed.tagSymlogLinearThresholds) { + for (const [tag, threshold] of Object.entries( + parsed.tagSymlogLinearThresholds + )) { + if (typeof threshold === 'number' && threshold > 0) { + scaleActions.push( + actions.metricsTagSymlogLinearThresholdChanged({ + tag, + symlogLinearThreshold: threshold, + }) + ); + } + } + } return scaleActions; } catch { return []; diff --git a/tensorbored/webapp/metrics/metrics_module.ts b/tensorbored/webapp/metrics/metrics_module.ts index 594080dd985..271aa635dd6 100644 --- a/tensorbored/webapp/metrics/metrics_module.ts +++ b/tensorbored/webapp/metrics/metrics_module.ts @@ -33,6 +33,7 @@ import { getMetricsLinkedTimeEnabled, getMetricsRangeSelectionEnabled, getMetricsScalarSmoothing, + getMetricsSymlogLinearThreshold, getMetricsStepSelectorEnabled, getMetricsTooltipSort, getMetricsSavingPinsEnabled, @@ -86,6 +87,15 @@ export function getSmoothingSettingFactory() { }); } +export function getSymlogLinearThresholdSettingFactory() { + return createSelector( + getMetricsSymlogLinearThreshold, + (symlogLinearThreshold) => { + return {symlogLinearThreshold}; + } + ); +} + export function getMetricsIgnoreOutliersSettingFactory() { return createSelector(getMetricsIgnoreOutliers, (ignoreOutliers) => { return {ignoreOutliers}; @@ -168,6 +178,9 @@ export function getRangeSelectionHeadersFactory() { PersistentSettingsConfigModule.defineGlobalSetting( getSmoothingSettingFactory ), + PersistentSettingsConfigModule.defineGlobalSetting( + getSymlogLinearThresholdSettingFactory + ), PersistentSettingsConfigModule.defineGlobalSetting( getMetricsIgnoreOutliersSettingFactory ), diff --git a/tensorbored/webapp/metrics/store/metrics_reducers.ts b/tensorbored/webapp/metrics/store/metrics_reducers.ts index f6cf2f29b23..e8e0bca7833 100644 --- a/tensorbored/webapp/metrics/store/metrics_reducers.ts +++ b/tensorbored/webapp/metrics/store/metrics_reducers.ts @@ -512,6 +512,7 @@ const {initialState, reducers: namespaceContextedReducer} = settings: METRICS_SETTINGS_DEFAULT, settingOverrides: {}, tagAxisScales: {}, + tagSymlogLinearThresholds: {}, visibleCardMap: new Map(), previousCardInteractions: { tagFilters: [], @@ -668,6 +669,13 @@ const reducer = createReducer( if (typeof partialSettings.scalarSmoothing === 'number') { metricsSettings.scalarSmoothing = partialSettings.scalarSmoothing; } + if ( + typeof partialSettings.symlogLinearThreshold === 'number' && + partialSettings.symlogLinearThreshold > 0 + ) { + metricsSettings.symlogLinearThreshold = + partialSettings.symlogLinearThreshold; + } if (typeof partialSettings.savingPinsEnabled === 'boolean') { metricsSettings.savingPinsEnabled = partialSettings.savingPinsEnabled; } @@ -915,6 +923,18 @@ const reducer = createReducer( }, }; }), + on( + actions.metricsChangeSymlogLinearThreshold, + (state, {symlogLinearThreshold}) => { + return { + ...state, + settingOverrides: { + ...state.settingOverrides, + symlogLinearThreshold, + }, + }; + } + ), on(actions.metricsChangeImageBrightness, (state, {brightnessInMilli}) => { return { ...state, @@ -1053,6 +1073,18 @@ const reducer = createReducer( }, }; }), + on( + actions.metricsTagSymlogLinearThresholdChanged, + (state, {tag, symlogLinearThreshold}) => { + return { + ...state, + tagSymlogLinearThresholds: { + ...state.tagSymlogLinearThresholds, + [tag]: symlogLinearThreshold, + }, + }; + } + ), on( actions.multipleTimeSeriesRequested, ( @@ -1914,6 +1946,8 @@ const reducer = createReducer( yAxisScale, xAxisScale, tagAxisScales, + symlogLinearThreshold, + tagSymlogLinearThresholds, } ) => { // Clear existing pins and apply profile's pins @@ -2017,8 +2051,12 @@ const reducer = createReducer( scalarSmoothing: smoothing, yAxisScale, xAxisScale, + ...(symlogLinearThreshold !== undefined && symlogLinearThreshold > 0 + ? {symlogLinearThreshold} + : {}), }, tagAxisScales, + tagSymlogLinearThresholds: tagSymlogLinearThresholds ?? {}, }; } ), diff --git a/tensorbored/webapp/metrics/store/metrics_selectors.ts b/tensorbored/webapp/metrics/store/metrics_selectors.ts index 47446a87d3e..482159ca595 100644 --- a/tensorbored/webapp/metrics/store/metrics_selectors.ts +++ b/tensorbored/webapp/metrics/store/metrics_selectors.ts @@ -371,6 +371,11 @@ export const getMetricsScalarPartitionNonMonotonicX = createSelector( (settings): boolean => settings.scalarPartitionNonMonotonicX ); +export const getMetricsSymlogLinearThreshold = createSelector( + selectSettings, + (settings): number => settings.symlogLinearThreshold +); + export const getMetricsImageBrightnessInMilli = createSelector( selectSettings, (settings): number => settings.imageBrightnessInMilli @@ -401,6 +406,24 @@ export const getMetricsXAxisScale = createSelector( (settings): ScaleType => settings.xAxisScale ); +export const getTagSymlogLinearThresholds = createSelector( + selectMetricsState, + (state): Record => state.tagSymlogLinearThresholds +); + +/** + * Returns the effective symlog linear threshold for a specific tag. + * Per-tag override takes priority over the global default. + */ +export const getEffectiveTagSymlogLinearThreshold = memoize((tag: string) => + createSelector( + getTagSymlogLinearThresholds, + getMetricsSymlogLinearThreshold, + (tagThresholds, globalThreshold): number => + tagThresholds[tag] ?? globalThreshold + ) +); + export const getTagAxisScales = createSelector( selectMetricsState, (state): Record => diff --git a/tensorbored/webapp/metrics/store/metrics_types.ts b/tensorbored/webapp/metrics/store/metrics_types.ts index 153241de050..12f8f1c24f8 100644 --- a/tensorbored/webapp/metrics/store/metrics_types.ts +++ b/tensorbored/webapp/metrics/store/metrics_types.ts @@ -242,6 +242,13 @@ export interface MetricsSettings { ignoreOutliers: boolean; xAxisType: XAxisType; scalarSmoothing: number; + /** + * Linear threshold for the symmetric log (SYMLOG10) scale. + * Controls the width of the linear region near zero. + * A value of c means the scale is approximately linear for |x| < c. + * Must be positive. Default is 1. + */ + symlogLinearThreshold: number; hideEmptyCards: boolean; /** * https://github.com/tensorflow/tensorboard/issues/3732 @@ -297,6 +304,11 @@ export interface MetricsNonNamespacedState { * Takes priority over the global yAxisScale/xAxisScale in settings. */ tagAxisScales: Record; + /** + * Per-tag symlog linear threshold overrides. Key is the tag name. + * Takes priority over the global symlogLinearThreshold in settings. + */ + tagSymlogLinearThresholds: Record; } export type MetricsState = NamespaceContextedState< @@ -315,6 +327,7 @@ export const METRICS_SETTINGS_DEFAULT: MetricsSettings = { xAxisType: XAxisType.STEP, hideEmptyCards: true, scalarSmoothing: 0.6, + symlogLinearThreshold: 1, scalarPartitionNonMonotonicX: false, imageBrightnessInMilli: 1000, imageContrastInMilli: 1000, diff --git a/tensorbored/webapp/metrics/testing.ts b/tensorbored/webapp/metrics/testing.ts index 5185cd1de82..add729c24ea 100644 --- a/tensorbored/webapp/metrics/testing.ts +++ b/tensorbored/webapp/metrics/testing.ts @@ -55,6 +55,7 @@ export function buildMetricsSettingsState( ignoreOutliers: false, xAxisType: XAxisType.WALL_TIME, scalarSmoothing: 0.3, + symlogLinearThreshold: 1, hideEmptyCards: true, scalarPartitionNonMonotonicX: false, imageBrightnessInMilli: 123, @@ -79,6 +80,7 @@ export function buildMetricsSettingsOverrides( ignoreOutliers: false, xAxisType: XAxisType.WALL_TIME, scalarSmoothing: 0.3, + symlogLinearThreshold: 1, hideEmptyCards: true, scalarPartitionNonMonotonicX: false, imageBrightnessInMilli: 123, @@ -119,6 +121,7 @@ function buildBlankState(): MetricsState { settings: buildMetricsSettingsState(), settingOverrides: {}, tagAxisScales: {}, + tagSymlogLinearThresholds: {}, cardList: [], cardToPinnedCopy: new Map(), cardToPinnedCopyCache: new Map(), diff --git a/tensorbored/webapp/metrics/views/card_renderer/scalar_card_component.ng.html b/tensorbored/webapp/metrics/views/card_renderer/scalar_card_component.ng.html index ca3dd67134b..3429b33fb08 100644 --- a/tensorbored/webapp/metrics/views/card_renderer/scalar_card_component.ng.html +++ b/tensorbored/webapp/metrics/views/card_renderer/scalar_card_component.ng.html @@ -96,6 +96,26 @@ X-axis scale: {{ getXScaleLabel() }} + + +
+ + +
+
+ + +
+ + +
+
@@ -103,6 +123,7 @@ [seriesMetadataMap]="chartMetadataMap" [xScaleType]="getEffectiveXScaleType()" [yScaleType]="yAxisScale" + [symlogLinearThreshold]="symlogLinearThreshold" [customXFormatter]="getCustomXFormatter()" [ignoreYOutliers]="ignoreOutliers" [tooltipTemplate]="tooltip" diff --git a/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_component.scss b/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_component.scss index 178c6116830..e2ad9561181 100644 --- a/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_component.scss +++ b/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_component.scss @@ -191,6 +191,26 @@ limitations under the License. } } +.symlog-threshold-menu { + padding: 8px 16px; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + + label { + white-space: nowrap; + } + + input { + width: 5em; + padding: 2px 4px; + border: 1px solid #ccc; + border-radius: 2px; + font-size: 12px; + } +} + :host-context(.dark-mode) { .superimposed-card { border-color: #9c27b0; diff --git a/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_component.ts b/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_component.ts index 5e1f0bef10d..dae8271aab8 100644 --- a/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_component.ts +++ b/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_component.ts @@ -77,6 +77,7 @@ export class SuperimposedCardComponent { @Input() xScaleType!: ScaleType; @Input() yAxisScale!: ScaleType; @Input() xAxisScale!: ScaleType; + @Input() symlogLinearThreshold: number = 1; @Input() useDarkMode!: boolean; @Input() forceSvg!: boolean; @Input() userViewBox: Extent | null = null; @@ -86,6 +87,8 @@ export class SuperimposedCardComponent { @Output() onViewBoxChange = new EventEmitter(); @Output() onFullWidthChanged = new EventEmitter(); @Output() onFullHeightChanged = new EventEmitter(); + @Output() + onSymlogLinearThresholdChanged = new EventEmitter(); @Output() onYAxisScaleChanged = new EventEmitter(); @Output() onXAxisScaleChanged = new EventEmitter(); @@ -167,6 +170,17 @@ export class SuperimposedCardComponent { ); } + onSymlogLinearThresholdInput(event: Event) { + const input = event.target as HTMLInputElement; + if (!input.value) { + return; + } + const value = parseFloat(input.value); + if (value > 0) { + this.onSymlogLinearThresholdChanged.emit(value); + } + } + resetDomain() { if (this.lineChart) { this.lineChart.viewBoxReset(); diff --git a/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_container.ts b/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_container.ts index 99d53883f8e..a2b1e472dab 100644 --- a/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_container.ts +++ b/tensorbored/webapp/metrics/views/card_renderer/superimposed_card_container.ts @@ -62,6 +62,7 @@ import {PluginType, ScalarStepDatum} from '../../data_source'; import { getMetricsIgnoreOutliers, getMetricsScalarSmoothing, + getMetricsSymlogLinearThreshold, getMetricsTooltipSort, getMetricsXAxisType, getMetricsYAxisScale, @@ -108,6 +109,7 @@ import {getFilteredRenderableRunsIds} from '../main_view/common_selectors'; [xScaleType]="xScaleType$ | async" [yAxisScale]="yAxisScale$ | async" [xAxisScale]="xAxisScale$ | async" + [symlogLinearThreshold]="symlogLinearThreshold$ | async" [useDarkMode]="useDarkMode$ | async" [forceSvg]="forceSvg$ | async" [userViewBox]="userViewBox$ | async" @@ -156,6 +158,9 @@ export class SuperimposedCardContainer implements OnInit, OnDestroy { this.yAxisScale$ = this.store.select(getMetricsYAxisScale); this.xAxisScale$ = this.store.select(getMetricsXAxisScale); this.scalarSmoothing$ = this.store.select(getMetricsScalarSmoothing); + this.symlogLinearThreshold$ = this.store.select( + getMetricsSymlogLinearThreshold + ); this.smoothingEnabled$ = this.store .select(getMetricsScalarSmoothing) .pipe(map((smoothing) => smoothing > 0)); @@ -186,6 +191,7 @@ export class SuperimposedCardContainer implements OnInit, OnDestroy { readonly yAxisScale$; readonly xAxisScale$; readonly scalarSmoothing$; + readonly symlogLinearThreshold$; readonly smoothingEnabled$; private readonly userViewBoxSubject = new BehaviorSubject( diff --git a/tensorbored/webapp/metrics/views/right_pane/settings_view_component.ng.html b/tensorbored/webapp/metrics/views/right_pane/settings_view_component.ng.html index dbf619d1c95..d494c081762 100644 --- a/tensorbored/webapp/metrics/views/right_pane/settings_view_component.ng.html +++ b/tensorbored/webapp/metrics/views/right_pane/settings_view_component.ng.html @@ -136,6 +136,28 @@

Scalars

+
+
+ Symlog Linear Threshold + +
+
+ +
+
+
Tooltip sorting method
mat-checkbox)):not(:last-child) { width: 5em; } +.symlog-linear-threshold .slider-input { + flex: none; + width: 6em; +} + +.symlog-linear-threshold .info { + $_dim: 15px; + height: $_dim; + margin-left: 5px; + width: $_dim; + min-width: $_dim; +} + .scalars-partition-x { align-items: center; display: flex; diff --git a/tensorbored/webapp/metrics/views/right_pane/settings_view_component.ts b/tensorbored/webapp/metrics/views/right_pane/settings_view_component.ts index 44d4a650174..10f56c7f066 100644 --- a/tensorbored/webapp/metrics/views/right_pane/settings_view_component.ts +++ b/tensorbored/webapp/metrics/views/right_pane/settings_view_component.ts @@ -132,6 +132,13 @@ export class SettingsViewComponent { auditTime(SLIDER_AUDIT_TIME_MS) ); + readonly symlogLinearThresholdControlChanged$ = new EventEmitter(); + @Input() symlogLinearThreshold: number = 1; + @Output() + symlogLinearThresholdChanged = this.symlogLinearThresholdControlChanged$.pipe( + auditTime(SLIDER_AUDIT_TIME_MS) + ); + @Input() scalarPartitionX!: boolean; @Output() scalarPartitionXToggled = new EventEmitter(); @@ -152,6 +159,19 @@ export class SettingsViewComponent { this.scalarSmoothingControlChanged$.emit(nextValue); } + onSymlogLinearThresholdInput(event: Event) { + const input = event.target as HTMLInputElement; + if (!input.value) { + return; + } + const nextValue = Math.max(0.001, parseFloat(input.value)); + + if (nextValue !== parseFloat(input.value)) { + input.value = String(nextValue); + } + this.symlogLinearThresholdControlChanged$.emit(nextValue); + } + readonly imageBrightnessSliderChanged$ = new EventEmitter(); @Input() imageBrightnessInMilli!: number; @Output() diff --git a/tensorbored/webapp/metrics/views/right_pane/settings_view_container.ts b/tensorbored/webapp/metrics/views/right_pane/settings_view_container.ts index 45ae6dcd8c9..238b44cb483 100644 --- a/tensorbored/webapp/metrics/views/right_pane/settings_view_container.ts +++ b/tensorbored/webapp/metrics/views/right_pane/settings_view_container.ts @@ -27,6 +27,7 @@ import { metricsChangeImageBrightness, metricsChangeImageContrast, metricsChangeScalarSmoothing, + metricsChangeSymlogLinearThreshold, metricsChangeTooltipSort, metricsChangeXAxisType, metricsEnableSavingPinsToggled, @@ -65,6 +66,8 @@ import { (histogramModeChanged)="onHistogramModeChanged($event)" [scalarSmoothing]="scalarSmoothing$ | async" (scalarSmoothingChanged)="onScalarSmoothingChanged($event)" + [symlogLinearThreshold]="symlogLinearThreshold$ | async" + (symlogLinearThresholdChanged)="onSymlogLinearThresholdChanged($event)" [scalarPartitionX]="scalarPartitionX$ | async" (scalarPartitionXToggled)="onScalarPartitionXToggled()" [imageBrightnessInMilli]="imageBrightnessInMilli$ | async" @@ -144,6 +147,9 @@ export class SettingsViewContainer { this.scalarSmoothing$ = this.store.select( selectors.getMetricsScalarSmoothing ); + this.symlogLinearThreshold$ = this.store.select( + selectors.getMetricsSymlogLinearThreshold + ); this.scalarPartitionX$ = this.store.select( selectors.getMetricsScalarPartitionNonMonotonicX ); @@ -180,6 +186,7 @@ export class SettingsViewContainer { readonly cardMinWidth$; readonly histogramMode$; readonly scalarSmoothing$; + readonly symlogLinearThreshold$; readonly scalarPartitionX$; readonly imageBrightnessInMilli$; readonly imageContrastInMilli$; @@ -216,6 +223,12 @@ export class SettingsViewContainer { this.store.dispatch(metricsChangeScalarSmoothing({smoothing})); } + onSymlogLinearThresholdChanged(symlogLinearThreshold: number) { + this.store.dispatch( + metricsChangeSymlogLinearThreshold({symlogLinearThreshold}) + ); + } + onScalarPartitionXToggled() { this.store.dispatch(metricsScalarPartitionNonMonotonicXToggled()); } diff --git a/tensorbored/webapp/persistent_settings/_data_source/persistent_settings_data_source.ts b/tensorbored/webapp/persistent_settings/_data_source/persistent_settings_data_source.ts index 0ad58626022..d632e3295fc 100644 --- a/tensorbored/webapp/persistent_settings/_data_source/persistent_settings_data_source.ts +++ b/tensorbored/webapp/persistent_settings/_data_source/persistent_settings_data_source.ts @@ -70,6 +70,10 @@ export class OSSSettingsConverter extends SettingsConverter< if (settings.scalarSmoothing !== undefined) { serializableSettings.scalarSmoothing = settings.scalarSmoothing; } + if (settings.symlogLinearThreshold !== undefined) { + serializableSettings.symlogLinearThreshold = + settings.symlogLinearThreshold; + } if (settings.tooltipSort !== undefined) { // TooltipSort is a string enum and has string values; no need to // serialize it differently to account for their unintended changes. @@ -140,6 +144,13 @@ export class OSSSettingsConverter extends SettingsConverter< settings.scalarSmoothing = backendSettings.scalarSmoothing; } + if ( + backendSettings.hasOwnProperty('symlogLinearThreshold') && + typeof backendSettings.symlogLinearThreshold === 'number' + ) { + settings.symlogLinearThreshold = backendSettings.symlogLinearThreshold; + } + if ( backendSettings.hasOwnProperty('ignoreOutliers') && typeof backendSettings.ignoreOutliers === 'boolean' diff --git a/tensorbored/webapp/persistent_settings/_data_source/types.ts b/tensorbored/webapp/persistent_settings/_data_source/types.ts index ded984fd292..7a1d9751857 100644 --- a/tensorbored/webapp/persistent_settings/_data_source/types.ts +++ b/tensorbored/webapp/persistent_settings/_data_source/types.ts @@ -31,6 +31,7 @@ export enum ThemeValue { */ export declare interface BackendSettings { scalarSmoothing?: number; + symlogLinearThreshold?: number; tooltipSort?: TooltipSort; ignoreOutliers?: boolean; autoReload?: boolean; @@ -57,6 +58,7 @@ export declare interface BackendSettings { */ export interface PersistableSettings { scalarSmoothing?: number; + symlogLinearThreshold?: number; tooltipSort?: TooltipSort; ignoreOutliers?: boolean; autoReload?: boolean; diff --git a/tensorbored/webapp/profile/effects/profile_effects.ts b/tensorbored/webapp/profile/effects/profile_effects.ts index ed7ee280000..e9d4a0c4868 100644 --- a/tensorbored/webapp/profile/effects/profile_effects.ts +++ b/tensorbored/webapp/profile/effects/profile_effects.ts @@ -33,6 +33,7 @@ import {getExperimentIdsFromRoute} from '../../selectors'; import {DataLoadState} from '../../types/data'; import { getMetricsScalarSmoothing, + getMetricsSymlogLinearThreshold, getMetricsTagFilter, getPinnedCardsWithMetadata, getUnresolvedImportedPinnedCards, @@ -40,6 +41,7 @@ import { getMetricsYAxisScale, getMetricsXAxisScale, getTagAxisScales, + getTagSymlogLinearThresholds, } from '../../metrics/store/metrics_selectors'; import { getRunColorOverride, @@ -370,6 +372,12 @@ export class ProfileEffects { ]) ) : {}, + ...(profile.symlogLinearThreshold !== undefined + ? {symlogLinearThreshold: profile.symlogLinearThreshold} + : {}), + ...(profile.tagSymlogLinearThresholds !== undefined + ? {tagSymlogLinearThresholds: profile.tagSymlogLinearThresholds} + : {}), }); }) ) @@ -474,12 +482,14 @@ export class ProfileEffects { this.store.select(getMetricsTagFilter), this.store.select(getRunSelectorRegexFilter), this.store.select(getMetricsScalarSmoothing), + this.store.select(getMetricsSymlogLinearThreshold), this.store.select(getRunUserSetGroupBy), this.store.select(getRunSelectionMap), this.store.select(getDashboardRuns), this.store.select(getMetricsYAxisScale), this.store.select(getMetricsXAxisScale), - this.store.select(getTagAxisScales) + this.store.select(getTagAxisScales), + this.store.select(getTagSymlogLinearThresholds) ), map( ([ @@ -492,12 +502,14 @@ export class ProfileEffects { tagFilter, runFilter, smoothing, + symlogLinearThreshold, groupBy, runSelectionMap, runs, yAxisScale, xAxisScale, tagAxisScales, + tagSymlogLinearThresholds, ]) => { // Convert pinned cards to CardUniqueInfo format const pinnedCardsInfo: CardUniqueInfo[] = pinnedCards.map((card) => { @@ -563,10 +575,14 @@ export class ProfileEffects { tagFilter, runFilter, smoothing, + symlogLinearThreshold, groupBy: profileGroupBy, yAxisScale: scaleTypeToName(yAxisScale), xAxisScale: scaleTypeToName(xAxisScale), tagAxisScales: buildTagAxisScalesForProfile(tagAxisScales), + ...(Object.keys(tagSymlogLinearThresholds).length > 0 + ? {tagSymlogLinearThresholds} + : {}), }; // Save to localStorage diff --git a/tensorbored/webapp/profile/types.ts b/tensorbored/webapp/profile/types.ts index 0ff763e47b2..2f63ed6c791 100644 --- a/tensorbored/webapp/profile/types.ts +++ b/tensorbored/webapp/profile/types.ts @@ -161,6 +161,12 @@ export interface ProfileData { */ smoothing: number; + /** + * Symlog linear threshold value (> 0, default 1). + * Controls how wide the linear region is near zero for symlog scale. + */ + symlogLinearThreshold?: number; + /** * Run grouping configuration. */ @@ -182,6 +188,12 @@ export interface ProfileData { * Takes priority over the global yAxisScale/xAxisScale. */ tagAxisScales?: Record; + + /** + * Per-tag symlog linear threshold overrides. Key is the tag name. + * Takes priority over the global symlogLinearThreshold. + */ + tagSymlogLinearThresholds?: Record; } /** @@ -244,6 +256,7 @@ export function createEmptyProfile(name: string): ProfileData { metricDescriptions: {}, runFilter: '', smoothing: 0.6, + symlogLinearThreshold: 1, groupBy: null, }; } diff --git a/tensorbored/webapp/widgets/line_chart_v2/lib/scale.ts b/tensorbored/webapp/widgets/line_chart_v2/lib/scale.ts index 50861481abe..9a6cbd42df6 100644 --- a/tensorbored/webapp/widgets/line_chart_v2/lib/scale.ts +++ b/tensorbored/webapp/widgets/line_chart_v2/lib/scale.ts @@ -18,14 +18,17 @@ import {Scale, ScaleType} from './scale_types'; export {ScaleType} from './scale_types'; -export function createScale(type: ScaleType): Scale { +export function createScale( + type: ScaleType, + symlogLinearThreshold: number = 1 +): Scale { switch (type) { case ScaleType.LINEAR: return new LinearScale(); case ScaleType.LOG10: return new Log10Scale(); case ScaleType.SYMLOG10: - return new SymLog10Scale(); + return new SymLog10Scale(symlogLinearThreshold); case ScaleType.TIME: return new TemporalScale(); default: @@ -187,27 +190,38 @@ class Log10Scale implements Scale { /** * Symmetric log scale (base 10) that handles both positive and negative values. - * Uses the log-modulus transformation: sign(x) * log10(|x| + 1) + * Uses the log-modulus transformation: sign(x) * log10(|x|/c + 1) + * where c is the linear threshold parameter. * * Key properties: * - Handles zero: symlog(0) = 0 * - Handles negative values: symlog(-x) = -symlog(x) - * - Behaves linearly near zero (when |x| << 1) + * - Behaves linearly near zero (when |x| << c) * - Behaves logarithmically for large |x| + * - c controls the width of the linear region: + * c = 1 (default): linear near |x| < 1 + * c = 10: linear near |x| < 10 + * c = 0.01: linear near |x| < 0.01 */ class SymLog10Scale implements Scale { + private readonly c: number; + + constructor(linearThreshold: number = 1) { + this.c = Math.max(linearThreshold, Number.MIN_VALUE); + } + /** - * Symmetric log transformation: sign(x) * log10(|x| + 1) + * Symmetric log transformation: sign(x) * log10(|x|/c + 1) */ private transform(x: number): number { - return Math.sign(x) * Math.log10(Math.abs(x) + 1); + return Math.sign(x) * Math.log10(Math.abs(x) / this.c + 1); } /** - * Inverse of symmetric log: sign(y) * (10^|y| - 1) + * Inverse of symmetric log: sign(y) * c * (10^|y| - 1) */ private untransform(y: number): number { - return Math.sign(y) * (Math.pow(10, Math.abs(y)) - 1); + return Math.sign(y) * this.c * (Math.pow(10, Math.abs(y)) - 1); } forward( diff --git a/tensorbored/webapp/widgets/line_chart_v2/lib/scale_test.ts b/tensorbored/webapp/widgets/line_chart_v2/lib/scale_test.ts index 0803b48b086..e3eccdca4d8 100644 --- a/tensorbored/webapp/widgets/line_chart_v2/lib/scale_test.ts +++ b/tensorbored/webapp/widgets/line_chart_v2/lib/scale_test.ts @@ -558,4 +558,60 @@ describe('line_chart_v2/lib/scale test', () => { }); }); }); + + describe('symlog10 with custom linearThreshold', () => { + it('with threshold=10, the linear region extends to |x| < 10', () => { + const scale = createScale(ScaleType.SYMLOG10, 10); + + // symlog(0) should always be 0 regardless of threshold + expect(scale.forward([-100, 100], [0, 100], 0)).toBeCloseTo(50, 0); + + // Forward/reverse should be cyclic consistent + const initialX = 42; + const forward = scale.forward([-100, 100], [0, 100], initialX); + const inverse = scale.reverse([-100, 100], [0, 100], forward); + expect(inverse).toBeCloseTo(initialX, 0); + }); + + it('with threshold=10, values near zero are more spread out', () => { + // With c=1 (default), symlog(5) = log10(5 + 1) ≈ 0.778 + // With c=10, symlog(5) = log10(5/10 + 1) = log10(1.5) ≈ 0.176 + // So c=10 keeps values under 10 closer to linear (more spread out near zero). + const scaleC1 = createScale(ScaleType.SYMLOG10, 1); + const scaleC10 = createScale(ScaleType.SYMLOG10, 10); + + // For small values (|x| << c), the scale should be more linear with higher c. + // Specifically, with c=10, 5 should be closer to the midpoint between 0 and 10. + const forwardC1 = scaleC1.forward([0, 100], [0, 100], 5); + const forwardC10 = scaleC10.forward([0, 100], [0, 100], 5); + + // With c=10, values up to 10 behave more linearly, so 5 (half of 10) + // maps closer to the halfway point in the 0-100 range. + // With c=1, log kicks in sooner, so 5 maps further up. + expect(forwardC10).toBeLessThan(forwardC1); + }); + + it('with threshold=0.01, the linear region is very narrow', () => { + const scale = createScale(ScaleType.SYMLOG10, 0.01); + + // Forward/reverse cyclic consistency + const initialX = -50; + const forward = scale.forward([-100, 100], [0, 100], initialX); + const inverse = scale.reverse([-100, 100], [0, 100], forward); + expect(inverse).toBeCloseTo(initialX, 0); + }); + + it('niceDomain works with custom threshold', () => { + const scale = createScale(ScaleType.SYMLOG10, 10); + const [low, high] = scale.niceDomain([1, 100]); + expect(low).toBeLessThan(1); + expect(high).toBeGreaterThan(100); + }); + + it('ticks works with custom threshold', () => { + const scale = createScale(ScaleType.SYMLOG10, 10); + const ticks = scale.ticks([1, 100], 5); + expect(ticks.length).toBeGreaterThan(0); + }); + }); }); diff --git a/tensorbored/webapp/widgets/line_chart_v2/line_chart_component.ts b/tensorbored/webapp/widgets/line_chart_v2/line_chart_component.ts index 34d0c549237..b7cbf9d5f78 100644 --- a/tensorbored/webapp/widgets/line_chart_v2/line_chart_component.ts +++ b/tensorbored/webapp/widgets/line_chart_v2/line_chart_component.ts @@ -129,6 +129,14 @@ export class LineChartComponent @Input() yScaleType: ScaleType = ScaleType.LINEAR; + /** + * Linear threshold for the SYMLOG10 scale type. + * Controls the width of the linear region near zero. + * Only affects SYMLOG10 scale types; ignored for other scale types. + */ + @Input() + symlogLinearThreshold: number = 1; + @Input() customXFormatter?: Formatter; @@ -206,13 +214,13 @@ export class LineChartComponent // OnChanges only decides whether props need to be updated and do not directly update // the line chart. - if (changes['xScaleType']) { - this.xScale = createScale(this.xScaleType); + if (changes['xScaleType'] || changes['symlogLinearThreshold']) { + this.xScale = createScale(this.xScaleType, this.symlogLinearThreshold); this.scaleUpdated = true; } - if (changes['yScaleType']) { - this.yScale = createScale(this.yScaleType); + if (changes['yScaleType'] || changes['symlogLinearThreshold']) { + this.yScale = createScale(this.yScaleType, this.symlogLinearThreshold); this.scaleUpdated = true; } @@ -308,6 +316,7 @@ export class LineChartComponent if ( changes['xScaleType'] || changes['yScaleType'] || + changes['symlogLinearThreshold'] || changes['ignoreYOutliers'] ) { return true;