Scales.Transform behaviour (scales v0.1.0)

Copy Markdown View Source

The transformation a continuous scale puts its values through before it trains on them, named by a value (name/0): values into transformed space (transform/2) and back (inverse/2), and an axis's default breaks (breaks/3), minor breaks (minor_breaks/3) and labels (format/3) there, as R's transformation objects carry them; for a transformation of numbers, also the derivatives both ways (derivative/2, inverse_derivative/2) and the range data takes in transformed space (trim_to_domain/2).

Breaks are found as on an axis showing an extent in transformed space: the extent is squished into the transformation's domain (squish/2), turned back into data units and broken there, and the breaks transformed back, in the order that leaves them, so a reversing transformation's descend. Some may fall outside the extent; which to keep is the caller's. Labels are written from the breaks turned back, 10^x under log10; a date's depend on the step chosen for the whole axis, so format/3 is handed the extent too. A date, date-time or time transformation also lays breaks a calendar or clock step apart (width/3).

A value the transformation cannot place, below a logarithm's or the square root's domain, becomes :nan, R's NaN; a value placed at an infinity is :infinity or :neg_infinity, and a missing one stays nil.

Compositions

{:compose, [first, second, ...]} puts values through each transformation in turn, R's transform_compose(): {:compose, [:log10, :reverse]} is a log axis drawn high to low. Its breaks and labels are its first part's, found on the extent turned back into that part's space, and n: reaches them as it reaches that part; its minor breaks are Scales.MinorBreaks.regular/3's, unreversed whichever way it runs, as R's; it has derivatives where every part has them, by the chain rule. Its domain is what every part takes: the first part's domain transformed, cut to the next part's, transformed again, and so on, turned back into data units, so {:compose, [:sqrt, :log10]} takes values from 1e-200 up, and a composition no value passes raises ArgumentError. A date, date-time or time transformation may come first, and the composition then takes and gives its values, its breaks, labels and steps (width/3) its first part's, so {:compose, [:date, :reverse]} is a date axis drawn late to early; anywhere else it raises ArgumentError.

iex> Scales.Transform.transform({:compose, [:log10, :reverse]}, [1, 10, 100])
[-0.0, -1.0, -2.0]

iex> Scales.Transform.format({:compose, [:log10, :reverse]}, [-0.0, -1.0, -2.0], {-2.1, 0.1})
["1", "10", "100"]

A transformation of your own

new/2 makes one from its functions, R's new_transform(), and it is given wherever a name is, a composition's parts too. Each function takes one value and returns a number, :infinity, :neg_infinity, :nan or nil. The transformation is handed every value in its domain, an integer as a float, and the domain's own ends among them, since an axis is bounded by them; nil and :nan are never handed over, and a value outside the domain becomes :nan without it. So a transformation on the whole line, the default domain, handles both infinities, as R's functions do through their arithmetic; its inverse is handed the transformation's values at the domain's ends, and handles them too.

iex> cube =
...>   Scales.Transform.new("cube",
...>     transform: fn
...>       :infinity -> :infinity
...>       :neg_infinity -> :neg_infinity
...>       value -> value * value * value
...>     end,
...>     inverse: fn
...>       :infinity -> :infinity
...>       :neg_infinity -> :neg_infinity
...>       position when position < 0 -> -:math.pow(-position, 1 / 3)
...>       position -> :math.pow(position, 1 / 3)
...>     end
...>   )
iex> Scales.Transform.transform(cube, [2, -0.5, nil])
[8.0, -0.125, nil]
iex> Scales.Transform.breaks(cube, {-1100.0, 1100.0})
[-1000.0, -125.0, 0.0, 125.0, 1000.0]
iex> Scales.Transform.format(cube, [-1000.0, -125.0, 0.0, 125.0, 1000.0], {-1100.0, 1100.0})
["-10", "-5", "0", "5", "10"]

Any distribution's quantile is one, as :probit is the normal's: the Cauchy distribution's on the probabilities from 0 to 1, its ends the infinities.

iex> cauchy =
...>   Scales.Transform.new("cauchy",
...>     transform: fn
...>       probability when probability == 0 -> :neg_infinity
...>       probability when probability == 1 -> :infinity
...>       probability -> :math.tan(:math.pi() * (probability - 0.5))
...>     end,
...>     inverse: fn
...>       :neg_infinity -> 0.0
...>       :infinity -> 1.0
...>       quantile -> 0.5 + :math.atan(quantile) / :math.pi()
...>     end,
...>     domain: {0, 1}
...>   )
iex> Scales.Transform.transform(cauchy, [0, 0.5, 1, 1.5])
[:neg_infinity, 0.0, :infinity, :nan]

Summary

Types

A break in transformed space: a number, or an infinity or NaN where R's lands one.

A transformation, by name: :identity, R's transform_identity(); :log (base e), :log2, :log10 and {:log, base}, R's transform_log(), transform_log2(), transform_log10() and transform_log(base); :log1p, R's transform_log1p(); :exp (base e) and {:exp, base}, R's transform_exp(); :asinh, R's transform_asinh(); :pseudo_log and {:pseudo_log, sigma: sigma, base: base}, either or both, R's transform_pseudo_log(sigma = 1, base = exp(1)); :sqrt, :reverse (each value negated) and :reciprocal, R's transform_sqrt() and its kin; :asn, the arcsine square root, and :atanh, R's transform_asn() and transform_atanh(); :logit and :probit, R's transform_logit() and transform_probit(); {:probability, :normal, mean: mean, sd: sd} and {:probability, :logistic, location: location, scale: scale}, either or both, R's transform_probability("norm", mean = 0, sd = 1) and transform_probability("logis", location = 0, scale = 1); {:boxcox, exponent} and {:boxcox, exponent, offset: offset}, R's transform_boxcox(p, offset = 0); {:modulus, exponent} and {:modulus, exponent, offset: offset}, R's transform_modulus(p, offset = 1); {:yj, exponent}, the Yeo-Johnson transformation, R's transform_yj(p); :timespan and {:timespan, unit}, a number counted in unit, :second to :week, as its seconds, R's transform_timespan(unit = "secs"); :date, a Date as its days since 1970-01-01, R's transform_date(); {:datetime, zone, database}, a DateTime or NaiveDateTime as its seconds since 1970-01-01 00:00 UTC, its breaks laid on the clock of zone, read by the time zone database database, R's transform_time(tz), the zone :default until a column settles it and :naive for naive date-times; :time, a Time as its seconds since midnight, R's transform_hms(); {:compose, [first, second, ...]}, each in turn, R's transform_compose(); and a transformation of your own, t/0, made by new/2.

t()

A transformation of your own, made by new/2: its name, its functions and its domain.

Functions

The default breaks, in transformed space, of an axis showing extent under the transformation name, in the order the transformation leaves them, some outside the extent, those at an end of a domain transformed to an infinity there, as logit's 0 and 1, which no extent reaches: extended breaks (Scales.Breaks) on the extent in data units, powers of the base and their multiples under a logarithm in a base (Scales.Breaks.log/2, so powers of e under :log), calendar dates under the date transformation and clock times under the date-time one, as R's pretty() finds them, and spans of time under the time and span of time ones, as Scales.Breaks.timespan/2 finds them. Under the Box-Cox, modulus and Yeo-Johnson transformations an end of the extent that turns back to an infinity, past a bound their values never reach, is set aside and the breaks found on the other end alone, as R's sit there; none where both do.

The derivative of the transformation name at each of values, R's d_transform, each a double (Scales.double/0), its infinities and NaN as R's arithmetic gives them. Only a transformation of numbers has one: a date's, date-time's, time's or span of time's raises FunctionClauseError.

The default labels of breaks, in transformed space, under the transformation name: its own breaks on an axis showing extent, found with options, breaks/3's, or, for :given, breaks it did not choose, the extent squished into the domain as for breaks/3. A number's are Scales.Labels.plain/1's, written from the breaks turned back, but the Box-Cox, modulus and Yeo-Johnson transformations write each of their own from the value in data units it was found from, since the round trip can keep as few as three of its digits, and a span of time's are Scales.Labels.timespan/2's; a date's in the format of the step its breaks were chosen at, so a date's or date-time's breaks found with n: are labelled when given the same n:, and a given date written in full.

Positions in transformed space turned back into data units by the transformation name.

The derivative of the inverse of the transformation name at each of positions, in transformed space, R's d_inverse, as derivative/2 gives the transformation's.

The default minor breaks between breaks on an axis showing extent under the transformation name, in transformed space: Scales.MinorBreaks.regular/3's, reversed for :reverse.

A transformation of your own named name, R's new_transform(), from its functions, each of one value: given wherever a transformation's name is, and as a composition's part. Its domain is the whole line unless domain: says, its breaks Scales.Breaks.extended/2's on an axis's limits in data units, its minor breaks Scales.MinorBreaks.regular/3's and its labels Scales.Labels.plain/1's of the breaks turned back, each rounded to 14 significant digits first, unless breaks:, minor_breaks: or labels: say. A missing function, an unknown option, a function of another arity or a domain not {low, high} raises NimbleOptions.ValidationError.

The extent {low, high}, in transformed space, squished into the domain of the transformation name, transformed: each end outside it moved onto it, as R's oob_squish() moves it, so log10's breaks are not sought below -100, and onto a bound the values of a transformation of every number never pass, the Yeo-Johnson transformation's 1 for an exponent of -1. A date's, date-time's or time's is never squished.

The values put through the transformation name.

The range values take in transformed space under the transformation name, R's trim_to_domain(): their range, nil and :nan left out, squished into the domain and its ends transformed, as {low, high}; :none with no value to range over, where R warns. Only a transformation of numbers takes one: a date's, date-time's or time's raises FunctionClauseError.

The breaks a step lays, written as Date.shift/2 takes one ([week: 2]), on an axis showing extent under the date, date-time or time transformation name, in transformed space, some outside it, as R's breaks_width() lays them: from the extent's start rounded down to the step's unit (a week to its Monday), every step. Only those three transformations have steps.

Types

break()

@type break() :: float() | :infinity | :neg_infinity | :nan

A break in transformed space: a number, or an infinity or NaN where R's lands one.

name()

@type name() ::
  :identity
  | :date
  | {:datetime, String.t() | :default | :naive, module()}
  | :time
  | :log
  | :log2
  | :log10
  | {:log, number()}
  | :log1p
  | :exp
  | {:exp, number()}
  | :asinh
  | :pseudo_log
  | {:pseudo_log, sigma: number(), base: number()}
  | :sqrt
  | :reverse
  | :reciprocal
  | :asn
  | :atanh
  | :logit
  | :probit
  | {:probability, :normal, mean: number(), sd: number()}
  | {:probability, :logistic, location: number(), scale: number()}
  | {:boxcox, number()}
  | {:boxcox, number(), [{:offset, number()}]}
  | {:modulus, number()}
  | {:modulus, number(), [{:offset, number()}]}
  | {:yj, number()}
  | :timespan
  | {:timespan, Scales.Breaks.timespan_unit()}
  | {:compose, [name(), ...]}
  | t()

A transformation, by name: :identity, R's transform_identity(); :log (base e), :log2, :log10 and {:log, base}, R's transform_log(), transform_log2(), transform_log10() and transform_log(base); :log1p, R's transform_log1p(); :exp (base e) and {:exp, base}, R's transform_exp(); :asinh, R's transform_asinh(); :pseudo_log and {:pseudo_log, sigma: sigma, base: base}, either or both, R's transform_pseudo_log(sigma = 1, base = exp(1)); :sqrt, :reverse (each value negated) and :reciprocal, R's transform_sqrt() and its kin; :asn, the arcsine square root, and :atanh, R's transform_asn() and transform_atanh(); :logit and :probit, R's transform_logit() and transform_probit(); {:probability, :normal, mean: mean, sd: sd} and {:probability, :logistic, location: location, scale: scale}, either or both, R's transform_probability("norm", mean = 0, sd = 1) and transform_probability("logis", location = 0, scale = 1); {:boxcox, exponent} and {:boxcox, exponent, offset: offset}, R's transform_boxcox(p, offset = 0); {:modulus, exponent} and {:modulus, exponent, offset: offset}, R's transform_modulus(p, offset = 1); {:yj, exponent}, the Yeo-Johnson transformation, R's transform_yj(p); :timespan and {:timespan, unit}, a number counted in unit, :second to :week, as its seconds, R's transform_timespan(unit = "secs"); :date, a Date as its days since 1970-01-01, R's transform_date(); {:datetime, zone, database}, a DateTime or NaiveDateTime as its seconds since 1970-01-01 00:00 UTC, its breaks laid on the clock of zone, read by the time zone database database, R's transform_time(tz), the zone :default until a column settles it and :naive for naive date-times; :time, a Time as its seconds since midnight, R's transform_hms(); {:compose, [first, second, ...]}, each in turn, R's transform_compose(); and a transformation of your own, t/0, made by new/2.

A base is a number above 1, a sigma, a standard deviation or a scale a number above 0, a mean, a location or an exponent a number, a Box-Cox offset a number from 0 up and a modulus offset one from 1 up, and a name holding any other raises FunctionClauseError wherever it is handed, where R also takes a base below 1, falling, and gives no axis at 1 or a scale of 0, and a modulus offset below 1, which no longer rises. A falling axis is the transformation in a base above 1 reversed.

t()

@type t() :: %Scales.Transform{
  breaks:
    ({float(), float()} -> [number()])
    | ({float(), float()}, keyword() -> [number()]),
  derivative: (term() -> term()) | nil,
  domain: {number() | :neg_infinity, number() | :infinity},
  inverse: (term() -> term()),
  inverse_derivative: (term() -> term()) | nil,
  labels: ([term()] -> [String.t()]),
  minor_breaks: ([float()], {float(), float()} -> [float()]),
  name: String.t(),
  transform: (term() -> term())
}

A transformation of your own, made by new/2: its name, its functions and its domain.

Functions

breaks(name, extent, options \\ [])

@spec breaks(name(), {float(), float()}, keyword()) :: [break()]

The default breaks, in transformed space, of an axis showing extent under the transformation name, in the order the transformation leaves them, some outside the extent, those at an end of a domain transformed to an infinity there, as logit's 0 and 1, which no extent reaches: extended breaks (Scales.Breaks) on the extent in data units, powers of the base and their multiples under a logarithm in a base (Scales.Breaks.log/2, so powers of e under :log), calendar dates under the date transformation and clock times under the date-time one, as R's pretty() finds them, and spans of time under the time and span of time ones, as Scales.Breaks.timespan/2 finds them. Under the Box-Cox, modulus and Yeo-Johnson transformations an end of the extent that turns back to an infinity, past a bound their values never reach, is set aside and the breaks found on the other end alone, as R's sit there; none where both do.

n: asks for that many: of Scales.Breaks.log/2 under a logarithm in a base, of R's pretty() under the date and date-time transformations, and of Scales.Breaks.extended/2 under the rest. The time and span of time transformations count no breaks, as R's take no number, and raise ArgumentError for it; Scales.Breaks.timespan/2 takes one.

Options

  • :n (non_neg_integer/0) - The number of breaks wanted, five when not given, for every transformation but :time and a span of time's: from 1 for the logarithms in a base (:log, :log2, :log10, {:log, base}), from 0 for dates and date-times, and from 2 for the rest, as R's own breaks take it.

Examples

iex> Scales.Transform.breaks(:identity, {9.225, 35.075})
[10.0, 15.0, 20.0, 25.0, 30.0, 35.0]

iex> Scales.Transform.breaks(:identity, {9.225, 35.075}, n: 10)
[10.0, 12.5, 15.0, 17.5, 20.0, 22.5, 25.0, 27.5, 30.0, 32.5, 35.0]

iex> Scales.Transform.breaks(:log10, {0.0, 3.0})
[0.0, 1.0, 2.0, 3.0]

iex> Scales.Transform.breaks(:log10, {0.0, 6.0}, n: 2)
[0.0, 4.0]

derivative(name, values)

@spec derivative(name(), [Scales.double()]) :: [Scales.double()]

The derivative of the transformation name at each of values, R's d_transform, each a double (Scales.double/0), its infinities and NaN as R's arithmetic gives them. Only a transformation of numbers has one: a date's, date-time's, time's or span of time's raises FunctionClauseError.

Examples

iex> Scales.Transform.derivative(:sqrt, [0.25, 0, -1, nil])
[1.0, :infinity, :nan, nil]

iex> Scales.Transform.derivative(:identity, [3, nil])
[1.0, 1.0]

format(name, breaks, extent, options \\ [])

@spec format(name(), [float()], {float(), float()} | :given, keyword()) :: [
  String.t()
]

The default labels of breaks, in transformed space, under the transformation name: its own breaks on an axis showing extent, found with options, breaks/3's, or, for :given, breaks it did not choose, the extent squished into the domain as for breaks/3. A number's are Scales.Labels.plain/1's, written from the breaks turned back, but the Box-Cox, modulus and Yeo-Johnson transformations write each of their own from the value in data units it was found from, since the round trip can keep as few as three of its digits, and a span of time's are Scales.Labels.timespan/2's; a date's in the format of the step its breaks were chosen at, so a date's or date-time's breaks found with n: are labelled when given the same n:, and a given date written in full.

Options

breaks/3's.

Examples

iex> Scales.Transform.format(:log10, [0.0, 1.0, 2.0], {0.0, 2.0})
["1", "10", "100"]

iex> extent = {19723.0, 19726.0}
iex> Scales.Transform.format(:date, Scales.Transform.breaks(:date, extent), extent)
["Dec 31", "Jan 01", "Jan 02", "Jan 03", "Jan 04", "Jan 05"]

iex> Scales.Transform.format(:date, [19723.0], :given)
["2024-01-01"]

iex> extent = {18262.0, 18993.0}
iex> breaks = Scales.Transform.breaks(:date, extent, n: 20)
iex> :date |> Scales.Transform.format(breaks, extent, n: 20) |> Enum.take(3)
["Jan 2020", "Feb 2020", "Mar 2020"]

inverse(name, positions)

@spec inverse(name(), [number()]) :: list()

Positions in transformed space turned back into data units by the transformation name.

Examples

iex> Scales.Transform.inverse(:log10, [0.0, 1.0, 2.0])
[1.0, 10.0, 100.0]

inverse_derivative(name, positions)

@spec inverse_derivative(name(), [Scales.double()]) :: [Scales.double()]

The derivative of the inverse of the transformation name at each of positions, in transformed space, R's d_inverse, as derivative/2 gives the transformation's.

Examples

iex> Scales.Transform.inverse_derivative(:sqrt, [0.5, -2, :infinity])
[1.0, -4.0, :infinity]

minor_breaks(name, breaks, extent)

@spec minor_breaks(name(), [float()], {float(), float()}) :: [float()]

The default minor breaks between breaks on an axis showing extent under the transformation name, in transformed space: Scales.MinorBreaks.regular/3's, reversed for :reverse.

Examples

iex> Scales.Transform.minor_breaks(:identity, [10.0, 20.0, 30.0], {5.0, 35.0})
[0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0]

new(name, options)

@spec new(String.t(), keyword()) :: t()

A transformation of your own named name, R's new_transform(), from its functions, each of one value: given wherever a transformation's name is, and as a composition's part. Its domain is the whole line unless domain: says, its breaks Scales.Breaks.extended/2's on an axis's limits in data units, its minor breaks Scales.MinorBreaks.regular/3's and its labels Scales.Labels.plain/1's of the breaks turned back, each rounded to 14 significant digits first, unless breaks:, minor_breaks: or labels: say. A missing function, an unknown option, a function of another arity or a domain not {low, high} raises NimbleOptions.ValidationError.

It is handed every value in its domain, the domain's ends among them, as the module documentation describes, and its inverse every position but nil and :nan: on the whole line it handles :infinity and :neg_infinity.

Options

  • :transform (function of arity 1) - Required. The transformation, of one value: a number, an integer as a float, or an infinity in the domain, to a number, :infinity, :neg_infinity, :nan or nil.

  • :inverse (function of arity 1) - Required. Its inverse, of one position in transformed space, returning as it does.

  • :derivative (function of arity 1) - Its derivative, of one value, for derivative/2; without one, derivative/2 raises.

  • :inverse_derivative (function of arity 1) - Its inverse's derivative, of one position, for inverse_derivative/2; without one, inverse_derivative/2 raises.

  • :domain - The values it takes, {low, high} in data units, each a number or an infinity, low below high. The default value is {:neg_infinity, :infinity}.

  • :breaks - An axis's breaks, from its limits in data units, {low, high}, to breaks in data units; one of two arguments is also handed breaks/3's options, so n: reaches it, and one of one argument takes none. The default value is &Scales.Breaks.extended/2.

  • :minor_breaks (function of arity 2) - An axis's minor breaks, from its breaks and its extent, both in transformed space, to minor breaks there. The default value is &Scales.MinorBreaks.regular/2.

  • :labels (function of arity 1) - An axis's labels, from its breaks turned back into data units; each rounded to 14 significant digits and written as Scales.Labels.plain/1 writes it when not given, since an inverse rarely gives a break back exactly.

Examples

iex> square_root =
...>   Scales.Transform.new("square root",
...>     transform: &:math.sqrt/1,
...>     inverse: fn position -> position * position end,
...>     domain: {0, 1.0e300}
...>   )
iex> Scales.Transform.transform(square_root, [4, -1, nil])
[2.0, :nan, nil]

squish(name, extent)

@spec squish(name(), {float(), float()}) :: {float(), float()}

The extent {low, high}, in transformed space, squished into the domain of the transformation name, transformed: each end outside it moved onto it, as R's oob_squish() moves it, so log10's breaks are not sought below -100, and onto a bound the values of a transformation of every number never pass, the Yeo-Johnson transformation's 1 for an exponent of -1. A date's, date-time's or time's is never squished.

Examples

iex> Scales.Transform.squish(:log10, {-200.0, 2.0})
{-100.0, 2.0}

transform(name, values)

@spec transform(name(), list()) :: list()

The values put through the transformation name.

Examples

iex> Scales.Transform.transform(:log10, [1, 10, 100, 0, -1, nil])
[0.0, 1.0, 2.0, :neg_infinity, :nan, nil]

iex> Scales.Transform.transform(:date, [~D[1970-01-11], nil])
[10.0, nil]

trim_to_domain(name, values)

@spec trim_to_domain(name(), [Scales.double()]) ::
  {Scales.double(), Scales.double()} | :none

The range values take in transformed space under the transformation name, R's trim_to_domain(): their range, nil and :nan left out, squished into the domain and its ends transformed, as {low, high}; :none with no value to range over, where R warns. Only a transformation of numbers takes one: a date's, date-time's or time's raises FunctionClauseError.

The range is taken before the transformation, so the reciprocal of a range across zero is the reciprocals of its ends, and an infinity is kept, not squished, so one outside the domain transforms to :nan, which makes both ends :nan, as R's range() makes them.

Examples

iex> Scales.Transform.trim_to_domain(:log10, [-5, 0, 10, 1000, nil])
{-100.0, 3.0}

iex> Scales.Transform.trim_to_domain(:reciprocal, [-5, 0, 10, 1000])
{-0.2, 0.001}

iex> Scales.Transform.trim_to_domain(:log10, [:neg_infinity, 5])
{:nan, :nan}

iex> Scales.Transform.trim_to_domain(:sqrt, [nil, :nan])
:none

width(name, step, extent)

@spec width(
  :date | {:datetime, term(), module()} | :time | {:compose, [name(), ...]},
  keyword(pos_integer()),
  {float(), float()}
) :: [float()]

The breaks a step lays, written as Date.shift/2 takes one ([week: 2]), on an axis showing extent under the date, date-time or time transformation name, in transformed space, some outside it, as R's breaks_width() lays them: from the extent's start rounded down to the step's unit (a week to its Monday), every step. Only those three transformations have steps.

Examples

iex> Scales.Transform.width(:date, [week: 2], {19723.0, 19750.0})
[19723.0, 19737.0, 19751.0]