Repository: mwaskom/seaborn

Issue:
Categorical scatter plots on symlog-scaled axis
Hi,

On the current dev version (eb2b5a2) and matplotlib 3.7.1, consider the following code that uses `stripplot` to draw two points on unscaled and symlog-scaled yaxis:
```python
import seaborn as sns
import matplotlib.pyplot as plt

x = [0.1,2]
y = [0.1,5]
fig, axs = plt.subplots(ncols=2)
sns.stripplot(x=x, y=y, ax=axs[0])
axs[0].set_yscale("symlog", base=10, linthresh=1)
axs[1].set_yscale("symlog", base=10, linthresh=1)
sns.stripplot(x=x, y=y, ax=axs[1])
axs[0].set_ylim(0,10**4)
axs[1].set_ylim(0,10**4)
axs[0].set_title("stripplot on unscaled axis")
axs[1].set_title("stripplot on symlog-scaled axis")
```
![image](https://user-images.githubusercontent.com/13831112/235433829-a7497557-f628-424b-bdb2-466353ef70f3.png)
The plot on the already-scaled yaxis contain values that were not provided. The plot changes (but still erroneous) if I set `linthresh` to something different (for example if using the linthresh default of 2). 
This also happens with `pointplot`. It works as expected with log-scaled axis or with pure matplotlib scatter calls. Couldn't reproduce using seaborn 0.12.2. 


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mwaskom/seaborn/pull/3440?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) Report
> Merging [#3440](https://app.codecov.io/gh/mwaskom/seaborn/pull/3440?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) (472003b) into [master](https://app.codecov.io/gh/mwaskom/seaborn/commit/23860365816440b050e9211e1c395a966de3c403?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) (2386036) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/mwaskom/seaborn/pull/3440/graphs/tree.svg?width=650&height=150&src=pr&token=SLuzHnA9Bl&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom)](https://app.codecov.io/gh/mwaskom/seaborn/pull/3440?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom)

```diff
@@           Coverage Diff           @@
##           master    #3440   +/-   ##
=======================================
  Coverage   98.32%   98.33%           
=======================================
  Files          77       77           
  Lines       24305    24321   +16     
=======================================
+ Hits        23899    23915   +16     
  Misses        406      406           
```


| [Files Changed](https://app.codecov.io/gh/mwaskom/seaborn/pull/3440?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) | Coverage Δ | |
|---|---|---|
| [seaborn/\_base.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3440?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9fYmFzZS5weQ==) | `97.66% <100.00%> (+0.11%)` | :arrow_up: |
| [seaborn/categorical.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3440?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9jYXRlZ29yaWNhbC5weQ==) | `98.89% <100.00%> (-0.01%)` | :arrow_down: |
| [seaborn/distributions.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3440?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9kaXN0cmlidXRpb25zLnB5) | `96.23% <100.00%> (-0.12%)` | :arrow_down: |
| [seaborn/relational.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3440?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9yZWxhdGlvbmFsLnB5) | `99.69% <100.00%> (+<0.01%)` | :arrow_up: |
| [tests/test\_base.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3440?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvdGVzdF9iYXNlLnB5) | `99.69% <100.00%> (+<0.01%)` | :arrow_up: |
| [tests/test\_categorical.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3440?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvdGVzdF9jYXRlZ29yaWNhbC5weQ==) | `99.12% <100.00%> (+<0.01%)` | :arrow_up: |

</details>

Patch:
diff --git a/seaborn/_base.py b/seaborn/_base.py
index 5fbb282c..8b890252 100644
--- a/seaborn/_base.py
+++ b/seaborn/_base.py
@@ -1025,9 +1025,7 @@ class VectorPlotter:
             )
 
         # Reduce to the semantics used in this plot
-        grouping_vars = [
-            var for var in grouping_vars if var in self.variables
-        ]
+        grouping_vars = [var for var in grouping_vars if var in self.variables]
 
         if from_comp_data:
             data = self.comp_data
@@ -1040,22 +1038,21 @@ class VectorPlotter:
         levels = self.var_levels.copy()
         if from_comp_data:
             for axis in {"x", "y"} & set(grouping_vars):
+                converter = self.converters[axis].iloc[0]
                 if self.var_types[axis] == "categorical":
                     if self._var_ordered[axis]:
                         # If the axis is ordered, then the axes in a possible
                         # facet grid are by definition "shared", or there is a
                         # single axis with a unique cat -> idx mapping.
                         # So we can just take the first converter object.
-                        converter = self.converters[axis].iloc[0]
                         levels[axis] = converter.convert_units(levels[axis])
                     else:
                         # Otherwise, the mappings may not be unique, but we can
                         # use the unique set of index values in comp_data.
                         levels[axis] = np.sort(data[axis].unique())
-                elif self.var_types[axis] == "datetime":
-                    levels[axis] = mpl.dates.date2num(levels[axis])
-                elif self.var_types[axis] == "numeric" and self._log_scaled(axis):
-                    levels[axis] = np.log10(levels[axis])
+                else:
+                    transform = converter.get_transform().transform
+                    levels[axis] = transform(converter.convert_units(levels[axis]))
 
         if grouping_vars:
 
@@ -1129,9 +1126,8 @@ class VectorPlotter:
                         # supporting `order` in categorical plots is tricky
                         orig = orig[orig.isin(self.var_levels[var])]
                     comp = pd.to_numeric(converter.convert_units(orig)).astype(float)
-                    if converter.get_scale() == "log":
-                        comp = np.log10(comp)
-                    parts.append(pd.Series(comp, orig.index, name=orig.name))
+                    transform = converter.get_transform().transform
+                    parts.append(pd.Series(transform(comp), orig.index, name=orig.name))
                 if parts:
                     comp_col = pd.concat(parts)
                 else:
@@ -1300,25 +1296,27 @@ class VectorPlotter:
 
         # TODO -- Add axes labels
 
-    def _log_scaled(self, axis):
-        """Return True if specified axis is log scaled on all attached axes."""
-        if not hasattr(self, "ax"):
-            return False
-
+    def _get_scale_transforms(self, axis):
+        """Return a function implementing the scale transform (or its inverse)."""
         if self.ax is None:
-            axes_list = self.facets.axes.flatten()
+            axis_list = [getattr(ax, f"{axis}axis") for ax in self.facets.axes.flat]
+            scales = {axis.get_scale() for axis in axis_list}
+            if len(scales) > 1:
+                # It is a simplifying assumption that faceted axes will always have
+                # the same scale (even if they are unshared and have distinct limits).
+                # Nothing in the seaborn API allows you to create a FacetGrid with
+                # a mixture of scales, although it's possible via matplotlib.
+                # This is constraining, but no more so than previous behavior that
+                # only (properly) handled log scales, and there are some places where
+                # it would be much too complicated to use axes-specific transforms.
+                err = "Cannot determine transform with mixed scales on faceted axes."
+                raise RuntimeError(err)
+            transform_obj = axis_list[0].get_transform()
         else:
-            axes_list = [self.ax]
-
-        log_scaled = []
-        for ax in axes_list:
-            data_axis = getattr(ax, f"{axis}axis")
-            log_scaled.append(data_axis.get_scale() == "log")
-
-        if any(log_scaled) and not all(log_scaled):
-            raise RuntimeError("Axis scaling is not consistent")
+            # This case is more straightforward
+            transform_obj = getattr(self.ax, f"{axis}axis").get_transform()
 
-        return any(log_scaled)
+        return transform_obj.transform, transform_obj.inverted().transform
 
     def _add_axis_labels(self, ax, default_x="", default_y=""):
         """Add axis labels if not present, set visibility to match ticklabels."""
diff --git a/seaborn/categorical.py b/seaborn/categorical.py
index 3180c9b7..c18f3a10 100644
--- a/seaborn/categorical.py
+++ b/seaborn/categorical.py
@@ -22,6 +22,7 @@ from seaborn.utils import (
     _check_argument,
     _draw_figure,
     _default_color,
+    _get_transform_functions,
     _normalize_kwargs,
     _version_predates,
 )
@@ -371,7 +372,7 @@ class _CategoricalPlotter(_RelationalPlotter):
     def _invert_scale(self, ax, data, vars=("x", "y")):
         """Undo scaling after computation so data are plotted correctly."""
         for var in vars:
-            _, inv = utils._get_transform_functions(ax, var[0])
+            _, inv = _get_transform_functions(ax, var[0])
             if var == self.orient and "width" in data:
                 hw = data["width"] / 2
                 data["edge"] = inv(data[var] - hw)
@@ -528,9 +529,7 @@ class _CategoricalPlotter(_RelationalPlotter):
             if not sub_data.empty:
                 point_collections[(ax, sub_data[self.orient].iloc[0])] = points
 
-        beeswarm = Beeswarm(
-            width=width, orient=self.orient, warn_thresh=warn_thresh,
-        )
+        beeswarm = Beeswarm(width=width, orient=self.orient, warn_thresh=warn_thresh)
         for (ax, center), points in point_collections.items():
             if points.get_offsets().shape[0] > 1:
 
@@ -627,6 +626,12 @@ class _CategoricalPlotter(_RelationalPlotter):
             capwidth = plot_kws.get("capwidths", 0.5 * data["width"])
 
             self._invert_scale(ax, data)
+            _, inv = _get_transform_functions(ax, value_var)
+            for stat in ["mean", "med", "q1", "q3", "cilo", "cihi", "whislo", "whishi"]:
+                stats[stat] = inv(stats[stat])
+            stats["fliers"] = stats["fliers"].map(inv)
+
+            linear_orient_scale = getattr(ax, f"get_{self.orient}scale")() == "linear"
 
             maincolor = self._hue_map(sub_vars["hue"]) if "hue" in sub_vars else color
             if fill:
@@ -651,8 +656,8 @@ class _CategoricalPlotter(_RelationalPlotter):
             default_kws = dict(
                 bxpstats=stats.to_dict("records"),
                 positions=data[self.orient],
-                # Set width to 0 with log scaled orient axis to avoid going < 0
-                widths=0 if self._log_scaled(self.orient) else data["width"],
+                # Set width to 0 to avoid going out of domain
+                widths=data["width"] if linear_orient_scale else 0,
                 patch_artist=fill,
                 vert=self.orient == "x",
                 manage_ticks=False,
@@ -673,7 +678,8 @@ class _CategoricalPlotter(_RelationalPlotter):
 
             # Reset artist widths after adding so everything stays positive
             ori_idx = ["x", "y"].index(self.orient)
-            if self._log_scaled(self.orient):
+
+            if not linear_orient_scale:
                 for i, box in enumerate(data.to_dict("records")):
                     p0 = box["edge"]
                     p1 = box["edge"] + box["width"]
@@ -702,9 +708,10 @@ class _CategoricalPlotter(_RelationalPlotter):
                         artists["medians"][i].set_data(verts)
 
                     if artists["caps"]:
+                        f_fwd, f_inv = _get_transform_functions(ax, self.orient)
                         for line in artists["caps"][2 * i:2 * i + 2]:
-                            p0 = 10 ** (np.log10(box[self.orient]) - capwidth[i] / 2)
-                            p1 = 10 ** (np.log10(box[self.orient]) + capwidth[i] / 2)
+                            p0 = f_inv(f_fwd(box[self.orient]) - capwidth[i] / 2)
+                            p1 = f_inv(f_fwd(box[self.orient]) + capwidth[i] / 2)
                             verts = line.get_xydata().T
                             verts[ori_idx][:] = p0, p1
                             line.set_data(verts)
@@ -769,8 +776,8 @@ class _CategoricalPlotter(_RelationalPlotter):
                                                  allow_empty=False):
 
             ax = self._get_axes(sub_vars)
-            _, inv_ori = utils._get_transform_functions(ax, self.orient)
-            _, inv_val = utils._get_transform_functions(ax, value_var)
+            _, inv_ori = _get_transform_functions(ax, self.orient)
+            _, inv_val = _get_transform_functions(ax, value_var)
 
             # Statistics
             lv_data = estimator(sub_data[value_var])
@@ -1010,8 +1017,8 @@ class _CategoricalPlotter(_RelationalPlotter):
                 offsets = span, span
 
             ax = violin["ax"]
-            _, invx = utils._get_transform_functions(ax, "x")
-            _, invy = utils._get_transform_functions(ax, "y")
+            _, invx = _get_transform_functions(ax, "x")
+            _, invy = _get_transform_functions(ax, "y")
             inv_pos = {"x": invx, "y": invy}[self.orient]
             inv_val = {"x": invx, "y": invy}[value_var]
 
@@ -1168,17 +1175,11 @@ class _CategoricalPlotter(_RelationalPlotter):
         markers = self._map_prop_with_hue("marker", markers, "o", plot_kws)
         linestyles = self._map_prop_with_hue("linestyle", linestyles, "-", plot_kws)
 
-        positions = self.var_levels[self.orient]
+        base_positions = self.var_levels[self.orient]
         if self.var_types[self.orient] == "categorical":
             min_cat_val = int(self.comp_data[self.orient].min())
             max_cat_val = int(self.comp_data[self.orient].max())
-            positions = [i for i in range(min_cat_val, max_cat_val + 1)]
-        else:
-            if self._log_scaled(self.orient):
-                positions = np.log10(positions)
-            if self.var_types[self.orient] == "datetime":
-                positions = mpl.dates.date2num(positions)
-        positions = pd.Index(positions, name=self.orient)
+            base_positions = [i for i in range(min_cat_val, max_cat_val + 1)]
 
         n_hue_levels = 0 if self._hue_map.levels is None else len(self._hue_map.levels)
         if dodge is True:
@@ -1192,11 +1193,14 @@ class _CategoricalPlotter(_RelationalPlotter):
 
             ax = self._get_axes(sub_vars)
 
+            ori_axis = getattr(ax, f"{self.orient}axis")
+            transform, _ = _get_transform_functions(ax, self.orient)
+            positions = transform(ori_axis.convert_units(base_positions))
             agg_data = sub_data if sub_data.empty else (
                 sub_data
                 .groupby(self.orient)
                 .apply(aggregator, agg_var)
-                .reindex(positions)
+                .reindex(pd.Index(positions, name=self.orient))
                 .reset_index()
             )
 
@@ -1316,14 +1320,12 @@ class _CategoricalPlotter(_RelationalPlotter):
             pos = np.array([row[self.orient], row[self.orient]])
             val = np.array([row[f"{var}min"], row[f"{var}max"]])
 
-            cw = capsize * self._native_width / 2
-            if self._log_scaled(self.orient):
-                log_pos = np.log10(pos)
-                cap = 10 ** (log_pos[0] - cw), 10 ** (log_pos[1] + cw)
-            else:
-                cap = pos[0] - cw, pos[1] + cw
-
             if capsize:
+
+                cw = capsize * self._native_width / 2
+                scl, inv = _get_transform_functions(ax, self.orient)
+                cap = inv(scl(pos[0]) - cw), inv(scl(pos[1]) + cw)
+
                 pos = np.concatenate([
                     [*cap, np.nan], pos, [np.nan, *cap]
                 ])
@@ -3220,13 +3222,12 @@ class Beeswarm:
             new_xy = new_xyr[:, :2]
         new_x_data, new_y_data = ax.transData.inverted().transform(new_xy).T
 
-        log_scale = getattr(ax, f"get_{self.orient}scale")() == "log"
-
         # Add gutters
+        t_fwd, t_inv = _get_transform_functions(ax, self.orient)
         if self.orient == "y":
-            self.add_gutters(new_y_data, center, log_scale=log_scale)
+            self.add_gutters(new_y_data, center, t_fwd, t_inv)
         else:
-            self.add_gutters(new_x_data, center, log_scale=log_scale)
+            self.add_gutters(new_x_data, center, t_fwd, t_inv)
 
         # Reposition the points so they do not overlap
         if self.orient == "y":
@@ -3330,20 +3331,14 @@ class Beeswarm:
             "No non-overlapping candidates found. This should not happen."
         )
 
-    def add_gutters(self, points, center, log_scale=False):
+    def add_gutters(self, points, center, trans_fwd, trans_inv):
         """Stop points from extending beyond their territory."""
         half_width = self.width / 2
-        if log_scale:
-            low_gutter = 10 ** (np.log10(center) - half_width)
-        else:
-            low_gutter = center - half_width
+        low_gutter = trans_inv(trans_fwd(center) - half_width)
         off_low = points < low_gutter
         if off_low.any():
             points[off_low] = low_gutter
-        if log_scale:
-            high_gutter = 10 ** (np.log10(center) + half_width)
-        else:
-            high_gutter = center + half_width
+        high_gutter = trans_inv(trans_fwd(center) + half_width)
         off_high = points > high_gutter
         if off_high.any():
             points[off_high] = high_gutter
diff --git a/seaborn/distributions.py b/seaborn/distributions.py
index e57a3b10..8cf7f231 100644
--- a/seaborn/distributions.py
+++ b/seaborn/distributions.py
@@ -26,6 +26,7 @@ from .axisgrid import (
 )
 from .utils import (
     remove_na,
+    _get_transform_functions,
     _kde_support,
     _normalize_kwargs,
     _check_argument,
@@ -273,21 +274,20 @@ class _DistributionPlotter(VectorPlotter):
             # This will require rethiniking if we add other semantics!
             hue_levels = self.var_levels["hue"]
             n = len(hue_levels)
+            f_fwd, f_inv = self._get_scale_transforms(self.data_variable)
             for key in curves:
+
                 level = dict(key)["hue"]
                 hist = curves[key].reset_index(name="heights")
                 level_idx = hue_levels.index(level)
-                if self._log_scaled(self.data_variable):
-                    log_min = np.log10(hist["edges"])
-                    log_max = np.log10(hist["edges"] + hist["widths"])
-                    log_width = (log_max - log_min) / n
-                    new_min = np.power(10, log_min + level_idx * log_width)
-                    new_max = np.power(10, log_min + (level_idx + 1) * log_width)
-                    hist["widths"] = new_max - new_min
-                    hist["edges"] = new_min
-                else:
-                    hist["widths"] /= n
-                    hist["edges"] += level_idx * hist["widths"]
+
+                a = f_fwd(hist["edges"])
+                b = f_fwd(hist["edges"] + hist["widths"])
+                w = (b - a) / n
+                new_min = f_inv(a + level_idx * w)
+                new_max = f_inv(a + (level_idx + 1) * w)
+                hist["widths"] = new_max - new_min
+                hist["edges"] = new_min
 
                 curves[key] = hist.set_index(["edges", "widths"])["heights"]
 
@@ -303,7 +303,6 @@ class _DistributionPlotter(VectorPlotter):
         common_norm,
         common_grid,
         estimate_kws,
-        log_scale,
         warn_singular=True,
     ):
 
@@ -358,8 +357,9 @@ class _DistributionPlotter(VectorPlotter):
                     warnings.warn(msg, UserWarning, stacklevel=4)
                 continue
 
-            if log_scale:
-                support = np.power(10, support)
+            # Invert the scaling of the support points
+            _, f_inv = self._get_scale_transforms(self.data_variable)
+            support = f_inv(support)
 
             # Apply a scaling factor so that the integral over all subsets is 1
             if common_norm:
@@ -446,13 +446,11 @@ class _DistributionPlotter(VectorPlotter):
             # TODO alternatively, clip at min/max bins?
             kde_kws.setdefault("cut", 0)
             kde_kws["cumulative"] = estimate_kws["cumulative"]
-            log_scale = self._log_scaled(self.data_variable)
             densities = self._compute_univariate_density(
                 self.data_variable,
                 common_norm,
                 common_bins,
                 kde_kws,
-                log_scale,
                 warn_singular=False,
             )
 
@@ -487,9 +485,10 @@ class _DistributionPlotter(VectorPlotter):
                 densities[key] *= hist_norm
 
             # Convert edges back to original units for plotting
-            if self._log_scaled(self.data_variable):
-                widths = np.power(10, edges + widths) - np.power(10, edges)
-                edges = np.power(10, edges)
+            ax = self._get_axes(sub_vars)
+            _, inv = _get_transform_functions(ax, self.data_variable)
+            widths = inv(edges + widths) - inv(edges)
+            edges = inv(edges)
 
             # Pack the histogram data and metadata together
             edges = edges + (1 - shrink) / 2 * widths
@@ -809,11 +808,14 @@ class _DistributionPlotter(VectorPlotter):
                 weights=sub_data.get("weights", None),
             )
 
-            # Check for log scaling on the data axis
-            if self._log_scaled("x"):
-                x_edges = np.power(10, x_edges)
-            if self._log_scaled("y"):
-                y_edges = np.power(10, y_edges)
+            # Get the axes for this plot
+            ax = self._get_axes(sub_vars)
+
+            # Invert the scale for the edges
+            _, inv_x = _get_transform_functions(ax, "x")
+            _, inv_y = _get_transform_functions(ax, "y")
+            x_edges = inv_x(x_edges)
+            y_edges = inv_y(y_edges)
 
             # Apply scaling to normalize across groups
             if estimator.stat != "count" and common_norm:
@@ -845,9 +847,6 @@ class _DistributionPlotter(VectorPlotter):
             if thresh is not None:
                 heights = np.ma.masked_less_equal(heights, thresh)
 
-            # Get the axes for this plot
-            ax = self._get_axes(sub_vars)
-
             # pcolormesh is going to turn the grid off, but we want to keep it
             # I'm not sure if there's a better way to get the grid state
             x_grid = any([l.get_visible() for l in ax.xaxis.get_gridlines()])
@@ -929,16 +928,12 @@ class _DistributionPlotter(VectorPlotter):
         if subsets and multiple in ("stack", "fill"):
             common_grid = True
 
-        # Check if the data axis is log scaled
-        log_scale = self._log_scaled(self.data_variable)
-
         # Do the computation
         densities = self._compute_univariate_density(
             self.data_variable,
             common_norm,
             common_grid,
             estimate_kws,
-            log_scale,
             warn_singular,
         )
 
@@ -1093,12 +1088,10 @@ class _DistributionPlotter(VectorPlotter):
                 continue
 
             # Transform the support grid back to the original scale
-            xx, yy = support
-            if self._log_scaled("x"):
-                xx = np.power(10, xx)
-            if self._log_scaled("y"):
-                yy = np.power(10, yy)
-            support = xx, yy
+            ax = self._get_axes(sub_vars)
+            _, inv_x = _get_transform_functions(ax, "x")
+            _, inv_y = _get_transform_functions(ax, "y")
+            support = inv_x(support[0]), inv_y(support[1])
 
             # Apply a scaling factor so that the integral over all subsets is 1
             if common_norm:
@@ -1245,9 +1238,12 @@ class _DistributionPlotter(VectorPlotter):
                 artist_kws["color"] = self._hue_map(sub_vars["hue"])
 
             # Return the data variable to the linear domain
-            # This needs an automatic solution; see GH2409
-            if self._log_scaled(self.data_variable):
-                vals = np.power(10, vals)
+            ax = self._get_axes(sub_vars)
+            _, inv = _get_transform_functions(ax, self.data_variable)
+            vals = inv(vals)
+
+            # Manually set the minimum value on a "log" scale
+            if isinstance(inv.__self__, mpl.scale.LogTransform):
                 vals[0] = -np.inf
 
             # Work out the orientation of the plot
@@ -1264,7 +1260,6 @@ class _DistributionPlotter(VectorPlotter):
                 top_edge = 1
 
             # Draw the line for this subset
-            ax = self._get_axes(sub_vars)
             artist, = ax.plot(*plot_args, **artist_kws)
             sticky_edges = getattr(artist.sticky_edges, stat_variable)
             sticky_edges[:] = 0, top_edge
@@ -1327,9 +1322,8 @@ class _DistributionPlotter(VectorPlotter):
         n = len(vector)
 
         # Return data to linear domain
-        # This needs an automatic solution; see GH2409
-        if self._log_scaled(var):
-            vector = np.power(10, vector)
+        _, inv = _get_transform_functions(ax, var)
+        vector = inv(vector)
 
         # We'll always add a single collection with varying colors
         if "hue" in self.variables:
diff --git a/seaborn/relational.py b/seaborn/relational.py
index 3786fe65..8596efa5 100644
--- a/seaborn/relational.py
+++ b/seaborn/relational.py
@@ -13,6 +13,7 @@ from .utils import (
     adjust_legend_subtitles,
     _default_color,
     _deprecate_ci,
+    _get_transform_functions,
 )
 from ._statistics import EstimateAggregator
 from .axisgrid import FacetGrid, _facet_docs
@@ -410,11 +411,11 @@ class _LinePlotter(_RelationalPlotter):
                 sub_data[f"{other}min"] = np.nan
                 sub_data[f"{other}max"] = np.nan
 
-            # TODO this is pretty ad hoc ; see GH2409
+            # Apply inverse axis scaling
             for var in "xy":
-                if self._log_scaled(var):
-                    for col in sub_data.filter(regex=f"^{var}"):
-                        sub_data[col] = np.power(10, sub_data[col])
+                _, inv = _get_transform_functions(ax, var)
+                for col in sub_data.filter(regex=f"^{var}"):
+                    sub_data[col] = inv(sub_data[col])
 
             # --- Draw the main line(s)
 
@@ -510,7 +511,7 @@ class _ScatterPlotter(_RelationalPlotter):
 
         # --- Determine the visual attributes of the plot
 
-        data = self.plot_data.dropna()
+        data = self.comp_data.dropna()
         if data.empty:
             return
 
@@ -519,6 +520,11 @@ class _ScatterPlotter(_RelationalPlotter):
         x = data.get("x", empty)
         y = data.get("y", empty)
 
+        # Apply inverse scaling to the coordinate variables
+        _, inv_x = _get_transform_functions(ax, "x")
+        _, inv_y = _get_transform_functions(ax, "y")
+        x, y = inv_x(x), inv_y(y)
+
         if "style" in self.variables:
             # Use a representative marker so scatter sets the edgecolor
             # properly for line art markers. We currently enforce either


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index e3c3dc28..c29e8a06 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -5,7 +5,7 @@ import matplotlib as mpl
 import matplotlib.pyplot as plt
 
 import pytest
-from numpy.testing import assert_array_equal
+from numpy.testing import assert_array_equal, assert_array_almost_equal
 from pandas.testing import assert_frame_equal
 
 from seaborn.axisgrid import FacetGrid
@@ -1096,48 +1096,36 @@ class TestVectorPlotter:
         p._attach(ax, log_scale=True)
         assert ax.xaxis.get_scale() == "log"
         assert ax.yaxis.get_scale() == "linear"
-        assert p._log_scaled("x")
-        assert not p._log_scaled("y")
 
         _, ax = plt.subplots()
         p = VectorPlotter(data=long_df, variables={"x": "x"})
         p._attach(ax, log_scale=2)
         assert ax.xaxis.get_scale() == "log"
         assert ax.yaxis.get_scale() == "linear"
-        assert p._log_scaled("x")
-        assert not p._log_scaled("y")
 
         _, ax = plt.subplots()
         p = VectorPlotter(data=long_df, variables={"y": "y"})
         p._attach(ax, log_scale=True)
         assert ax.xaxis.get_scale() == "linear"
         assert ax.yaxis.get_scale() == "log"
-        assert not p._log_scaled("x")
-        assert p._log_scaled("y")
 
         _, ax = plt.subplots()
         p = VectorPlotter(data=long_df, variables={"x": "x", "y": "y"})
         p._attach(ax, log_scale=True)
         assert ax.xaxis.get_scale() == "log"
         assert ax.yaxis.get_scale() == "log"
-        assert p._log_scaled("x")
-        assert p._log_scaled("y")
 
         _, ax = plt.subplots()
         p = VectorPlotter(data=long_df, variables={"x": "x", "y": "y"})
         p._attach(ax, log_scale=(True, False))
         assert ax.xaxis.get_scale() == "log"
         assert ax.yaxis.get_scale() == "linear"
-        assert p._log_scaled("x")
-        assert not p._log_scaled("y")
 
         _, ax = plt.subplots()
         p = VectorPlotter(data=long_df, variables={"x": "x", "y": "y"})
         p._attach(ax, log_scale=(False, 2))
         assert ax.xaxis.get_scale() == "linear"
         assert ax.yaxis.get_scale() == "log"
-        assert not p._log_scaled("x")
-        assert p._log_scaled("y")
 
     def test_attach_converters(self, long_df):
 
@@ -1161,6 +1149,61 @@ class TestVectorPlotter:
         assert p.ax is None
         assert p.facets == g
 
+    def test_scale_transform_identity(self, long_df):
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "x"})
+        p._attach(ax)
+        fwd, inv = p._get_scale_transforms("x")
+
+        x = np.arange(1, 10)
+        assert_array_equal(fwd(x), x)
+        assert_array_equal(inv(x), x)
+
+    def test_scale_transform_identity_facets(self, long_df):
+
+        g = FacetGrid(long_df, col="a")
+        p = VectorPlotter(data=long_df, variables={"x": "x", "col": "a"})
+        p._attach(g)
+
+        fwd, inv = p._get_scale_transforms("x")
+        x = np.arange(1, 10)
+        assert_array_equal(fwd(x), x)
+        assert_array_equal(inv(x), x)
+
+    def test_scale_transform_log(self, long_df):
+
+        _, ax = plt.subplots()
+        ax.set_xscale("log")
+        p = VectorPlotter(data=long_df, variables={"x": "x"})
+        p._attach(ax)
+
+        fwd, inv = p._get_scale_transforms("x")
+        x = np.arange(1, 4)
+        assert_array_almost_equal(fwd(x), np.log10(x))
+        assert_array_almost_equal(inv(x), 10 ** x)
+
+    def test_scale_transform_facets(self, long_df):
+
+        g = FacetGrid(long_df, col="a")
+        p = VectorPlotter(data=long_df, variables={"x": "x", "col": "a"})
+        p._attach(g)
+
+        fwd, inv = p._get_scale_transforms("x")
+        x = np.arange(4)
+        assert_array_equal(inv(fwd(x)), x)
+
+    def test_scale_transform_mixed_facets(self, long_df):
+
+        g = FacetGrid(long_df, col="a", sharex=False)
+        g.axes.flat[0].set_xscale("log")
+        p = VectorPlotter(data=long_df, variables={"x": "x", "col": "a"})
+        p._attach(g)
+
+        err = "Cannot determine transform with mixed scales on faceted axes"
+        with pytest.raises(RuntimeError, match=err):
+            p._get_scale_transforms("x")
+
     def test_attach_shared_axes(self, long_df):
 
         g = FacetGrid(long_df)
diff --git a/tests/test_categorical.py b/tests/test_categorical.py
index 4300987d..f4520c88 100644
--- a/tests/test_categorical.py
+++ b/tests/test_categorical.py
@@ -806,13 +806,13 @@ class TestBoxPlot(SharedAxesLevelTests):
         p25, p50, p75 = np.percentile(data, [25, 50, 75])
 
         box = self.get_box_verts(bxp.box)
-        assert box[val_idx].min() == p25
-        assert box[val_idx].max() == p75
+        assert box[val_idx].min() == approx(p25, 1e-3)
+        assert box[val_idx].max() == approx(p75, 1e-3)
         assert box[pos_idx].min() == approx(pos - width / 2)
         assert box[pos_idx].max() == approx(pos + width / 2)
 
         med = bxp.median.get_xydata().T
-        assert tuple(med[val_idx]) == (p50, p50)
+        assert np.allclose(med[val_idx], (p50, p50), rtol=1e-3)
         assert np.allclose(med[pos_idx], (pos - width / 2, pos + width / 2))
 
     def check_whiskers(self, bxp, data, orient, pos, capsize=0.4, whis=1.5):
@@ -831,13 +831,13 @@ class TestBoxPlot(SharedAxesLevelTests):
         adj_lo = data[data >= (p25 - iqr * whis)].min()
         adj_hi = data[data <= (p75 + iqr * whis)].max()
 
-        assert whis_lo[val_idx].max() == p25
+        assert whis_lo[val_idx].max() == approx(p25, 1e-3)
         assert whis_lo[val_idx].min() == approx(adj_lo)
         assert np.allclose(whis_lo[pos_idx], (pos, pos))
         assert np.allclose(caps_lo[val_idx], (adj_lo, adj_lo))
         assert np.allclose(caps_lo[pos_idx], (pos - capsize / 2, pos + capsize / 2))
 
-        assert whis_hi[val_idx].min() == p75
+        assert whis_hi[val_idx].min() == approx(p75, 1e-3)
         assert whis_hi[val_idx].max() == approx(adj_hi)
         assert np.allclose(whis_hi[pos_idx], (pos, pos))
         assert np.allclose(caps_hi[val_idx], (adj_hi, adj_hi))
@@ -944,6 +944,18 @@ class TestBoxPlot(SharedAxesLevelTests):
                 widths.append(np.ptp(coords))
         assert np.std(widths) == approx(0)
 
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_log_data_scale(self, long_df, orient):
+
+        var = {"x": "y", "y": "x"}[orient]
+        s = long_df["z"]
+        ax = mpl.figure.Figure().subplots()
+        getattr(ax, f"set_{var}scale")("log")
+        boxplot(**{var: s}, whis=np.inf, ax=ax)
+        bxp = ax.containers[0][0]
+        self.check_box(bxp, s, orient, 0)
+        self.check_whiskers(bxp, s, orient, 0, whis=np.inf)
+
     def test_color(self, long_df):
 
         color = "#123456"
@@ -3062,12 +3074,13 @@ class TestBeeswarm:
         p = Beeswarm(width=1)
 
         points = np.zeros(10)
-        assert_array_equal(points, p.add_gutters(points, 0))
+        t_fwd = t_inv = lambda x: x
+        assert_array_equal(points, p.add_gutters(points, 0, t_fwd, t_inv))
 
         points = np.array([0, -1, .4, .8])
         msg = r"50.0% of the points cannot be placed.+$"
         with pytest.warns(UserWarning, match=msg):
-            new_points = p.add_gutters(points, 0)
+            new_points = p.add_gutters(points, 0, t_fwd, t_inv)
         assert_array_equal(new_points, np.array([0, -.5, .4, .5]))