Мин/макс коллекций, содержащих NaN (обработка несравнимости при заказе)

Я просто столкнулся с неприятной ошибкой в ​​результате следующего поведения:

scala> List(1.0, 2.0, 3.0, Double.NaN).min
res1: Double = NaN

scala> List(1.0, 2.0, 3.0, Double.NaN).max
res2: Double = NaN

Я понимаю, что для парного сравнения иногда может быть предпочтительнее иметь max(NaN, 0) = NaN, и это, вероятно, является причиной того, почему java.lang.Double.compare следует этому соглашению (похоже, стандарт IEEE). Однако для коллекции я действительно думаю, что это странное соглашение. Ведь в приведенной выше коллекции содержатся действительные числа; и эти цифры имеют ясный максимум и минимум. По-моему, представление о том, что максимальное количество коллекции не является числом, является противоречием, так как NaN не является числом, поэтому оно не может быть максимальным или минимальным "числом" коллекции, если только нет действительные числа вообще; в этом случае имеет смысл, что максимум "не является числом". Семантически функции min и max вырождаются в проверку того, содержит ли коллекция NaN. Поскольку существует более подходящий способ проверить существование NaN (например, collection.find(_.isNaN)), было бы здорово поддерживать семантически значимые минимальные/максимальные значения в коллекциях.

Итак, мой вопрос: каков наилучший подход к получению поведения, чтобы игнорировать существование NaN? Я вижу две возможности:

  • Фильтрация NaN до вызова min/max. Поскольку это требует явного решения проблемы во всех местах и ​​может привести к штрафам за производительность, я бы предпочел что-то более простое.

  • Было бы здорово иметь своего рода NaN-игнорирующее упорядочение, которое можно использовать как неявное упорядочение, где это необходимо. Я попробовал следующее:

      object NanAwareOrdering extends Ordering[Double] {
        def compare(x: Double, y: Double) = {
          if (x.isNaN()) {
            +1 // without checking x, return y < x
          } else if (y.isNaN()) {
            -1 // without checking y, return x < y
          } else {
            java.lang.Double.compare(x, y)
          }
        }
      }
    

    Однако этот подход, похоже, зависит от того, интересно ли мне находить min или max, т.е.

     scala> List(1.0, 2.0, 3.0, Double.NaN).min(NanAwareOrdering)
     res7: Double = 1.0
    
     scala> List(1.0, 2.0, 3.0, Double.NaN).max(NanAwareOrdering)
     res8: Double = NaN
    

    Это означает, что мне придется иметь два NanAwareOrdering в зависимости от того, хочу ли я минимум или максимум, который запретил бы иметь implicit val. Поэтому мой вопрос: как я могу определить порядок для обработки обоих случаев сразу?

Update:

Ради полноты: во время анализа вопроса я понял, что предпосылка "вырождается до проверки для NaN" на самом деле ошибочна. На самом деле, я думаю, это еще более уродливо:

scala> List(1.0, Double.NaN).min
res1: Double = NaN

scala> List(Double.NaN, 1.0).min
res2: Double = 1.0

Ответ 1

Отказ от ответственности: я добавлю свой собственный ответ на вопрос на всякий случай, если кто-либо еще интересуется более подробными сведениями по этому вопросу.

Некоторая теория...

Похоже, эта проблема сложнее, чем я ожидал. Как уже отмечал Алексей Романов, понятие несравнимости потребовало бы, чтобы функции max/min выполняли частичный порядок. К сожалению, Алексей также прав в том, что общая функция max/min, основанная на частичном порядке, не имеет смысла: подумайте о случае, когда частичный порядок определяет только отношения внутри определенных групп, но сами группы полностью независимы от (например, элементы {a, b, c, d} только с двумя отношениями a < b и c < d; мы имели бы два max/min). В этом отношении можно даже утверждать, что формально max/min всегда должен возвращать два значения: NaN и соответствующий действительный min/max, поскольку NaN сама по себе также является экстремальным значением в своей собственной группе отношений.

Таким образом, из-за того, что частичные порядки являются слишком общими/сложными, функции min/max принимают Ordering. К сожалению, общий порядок не допускает понятия несравнимости. Рассмотрение трех определяющих свойств полного порядка делает довольно очевидным, что "игнорирование NaN" формально невозможно:

  • Если a ≤ b и b ≤ a, то a = b (антисимметрия)
  • Если a ≤ b и b ≤ c, то a ≤ c (транзитивность)
  • a ≤ b или b ≤ a (совокупность)

... и практика...

Поэтому, пытаясь придумать реализацию Ordering для выполнения нашего желаемого поведения min/max, ясно, что мы должны что-то нарушать (и нести последствия). Реализация min/max/minBy/maxBy в TraversableOnce следует шаблону (для min):

reduceLeft((x, y) => if (cmp.lteq(x, y)) x else y)

и gteq для вариантов max. Это дало мне представление о "смещении левого" сравнения, то есть:

x   <comparison_operator> NaN    is always true to keep x in the reduction
NaN <comparison_operator> x      is always false to inject x into the reduction

Результирующая реализация такого "левого предвзятого" упорядочения будет выглядеть так:

object BiasedOrdering extends Ordering[Double] {
  def compare(x: Double, y: Double) = java.lang.Double.compare(x, y) // this is inconsistent, but the same goes for Double.Ordering

  override def lteq(x: Double, y: Double): Boolean  = if (x.isNaN() && !y.isNaN) false else if (!x.isNaN() && y.isNaN) true else if (x.isNaN() && y.isNaN) true  else compare(x, y) <= 0
  override def gteq(x: Double, y: Double): Boolean  = if (x.isNaN() && !y.isNaN) false else if (!x.isNaN() && y.isNaN) true else if (x.isNaN() && y.isNaN) true  else compare(x, y) >= 0
  override def lt(x: Double, y: Double): Boolean    = if (x.isNaN() && !y.isNaN) false else if (!x.isNaN() && y.isNaN) true else if (x.isNaN() && y.isNaN) false else compare(x, y) < 0
  override def gt(x: Double, y: Double): Boolean    = if (x.isNaN() && !y.isNaN) false else if (!x.isNaN() && y.isNaN) true else if (x.isNaN() && y.isNaN) false else compare(x, y) > 0
  override def equiv(x: Double, y: Double): Boolean = if (x.isNaN() && !y.isNaN) false else if (!x.isNaN() && y.isNaN) true else if (x.isNaN() && y.isNaN) true  else compare(x, y) == 0

}

... проанализировано:

В настоящее время я пытаюсь выяснить:

  • как этот порядок сравнивается с порядком по умолчанию,
  • где мы нарушаем общие свойства порядка,
  • и каковы потенциальные проблемы.

Я сравниваю это с Scala порядком по умолчанию Ordering.Double и следующим порядком, который непосредственно получен из java.lang.Double.compare:

object OrderingDerivedFromCompare extends Ordering[Double] {
  def compare(x: Double, y: Double) = {
    java.lang.Double.compare(x, y)
  }
}

Одним интересным свойством Scala порядка по умолчанию Ordering.Double является то, что он перезаписывает все функции-члены сравнения с помощью операторов нативного численного сравнения языка (<, <=, ==, >=, >), поэтому результаты сравнения идентичны, как если бы мы сравнивали бы непосредственно с этими операторами. Ниже показаны все возможные отношения между NaN и действительным числом для трех упорядочений:

Ordering.Double             0.0 >  NaN = false
Ordering.Double             0.0 >= NaN = false
Ordering.Double             0.0 == NaN = false
Ordering.Double             0.0 <= NaN = false
Ordering.Double             0.0 <  NaN = false
OrderingDerivedFromCompare  0.0 >  NaN = false
OrderingDerivedFromCompare  0.0 >= NaN = false
OrderingDerivedFromCompare  0.0 == NaN = false
OrderingDerivedFromCompare  0.0 <= NaN = true
OrderingDerivedFromCompare  0.0 <  NaN = true
BiasedOrdering              0.0 >  NaN = true
BiasedOrdering              0.0 >= NaN = true
BiasedOrdering              0.0 == NaN = true
BiasedOrdering              0.0 <= NaN = true
BiasedOrdering              0.0 <  NaN = true

Ordering.Double             NaN >  0.0 = false
Ordering.Double             NaN >= 0.0 = false
Ordering.Double             NaN == 0.0 = false
Ordering.Double             NaN <= 0.0 = false
Ordering.Double             NaN <  0.0 = false
OrderingDerivedFromCompare  NaN >  0.0 = true
OrderingDerivedFromCompare  NaN >= 0.0 = true
OrderingDerivedFromCompare  NaN == 0.0 = false
OrderingDerivedFromCompare  NaN <= 0.0 = false
OrderingDerivedFromCompare  NaN <  0.0 = false
BiasedOrdering              NaN >  0.0 = false
BiasedOrdering              NaN >= 0.0 = false
BiasedOrdering              NaN == 0.0 = false
BiasedOrdering              NaN <= 0.0 = false
BiasedOrdering              NaN <  0.0 = false

Ordering.Double             NaN >  NaN = false
Ordering.Double             NaN >= NaN = false
Ordering.Double             NaN == NaN = false
Ordering.Double             NaN <= NaN = false
Ordering.Double             NaN <  NaN = false
OrderingDerivedFromCompare  NaN >  NaN = false
OrderingDerivedFromCompare  NaN >= NaN = true
OrderingDerivedFromCompare  NaN == NaN = true
OrderingDerivedFromCompare  NaN <= NaN = true
OrderingDerivedFromCompare  NaN <  NaN = false
BiasedOrdering              NaN >  NaN = false
BiasedOrdering              NaN >= NaN = true
BiasedOrdering              NaN == NaN = true
BiasedOrdering              NaN <= NaN = true
BiasedOrdering              NaN <  NaN = false

Мы можем видеть, что:

  • только OrderingDerivedFromCompare выполняет свойства полного порядка. Исходя из этого результата рассуждение java.lang.Double.compare становится намного яснее: размещение NaN в верхнем конце всего порядка просто избегает любого противоречия!
  • Scala порядок по умолчанию и предвзятый порядок нарушают многие условия совокупности. Scala порядок по умолчанию всегда возвращает false, тогда как для смещенного порядка это зависит от позиции. Поскольку оба ведут к противоречиям, трудно понять, что может привести к более серьезным проблемам.

Теперь, к нашей актуальной проблеме, функции min/max. Для OrderingDerivedFromCompare теперь ясно, что мы должны получить - NaN - просто самое большое значение, поэтому ясно, что он получает его как max, независимо от того, как упорядочены элементы в списке:

OrderingDerivedFromCompare  List(1.0, 2.0, 3.0, Double.NaN).min = 1.0
OrderingDerivedFromCompare  List(Double.NaN, 1.0, 2.0, 3.0).min = 1.0
OrderingDerivedFromCompare  List(1.0, 2.0, 3.0, Double.NaN).max = NaN
OrderingDerivedFromCompare  List(Double.NaN, 1.0, 2.0, 3.0).max = NaN

Теперь

Ordering.Double             List(1.0, 2.0, 3.0, Double.NaN).min = NaN
Ordering.Double             List(Double.NaN, 1.0, 2.0, 3.0).min = 1.0
Ordering.Double             List(1.0, 2.0, 3.0, Double.NaN).max = NaN
Ordering.Double             List(Double.NaN, 1.0, 2.0, 3.0).max = 3.0

Фактически порядок элементов становится актуальным (в результате возвращения false для каждого сравнения в reduceLeft). "Левое смещение", очевидно, решает эту проблему, приводя к постоянным результатам:

BiasedOrdering              List(1.0, 2.0, 3.0, Double.NaN).min = 1.0
BiasedOrdering              List(Double.NaN, 1.0, 2.0, 3.0).min = 1.0
BiasedOrdering              List(1.0, 2.0, 3.0, Double.NaN).max = 3.0
BiasedOrdering              List(Double.NaN, 1.0, 2.0, 3.0).max = 3.0

К сожалению, я все еще не в состоянии полностью ответить на все вопросы здесь. Некоторые оставшиеся пункты:

  • Почему порядок Scala по умолчанию определяется так, как он есть? В настоящее время обработка NaN кажется довольно ошибочной. Очень опасная деталь Ordering.Double заключается в том, что функция compare фактически делегирует java.lang.Double.compare, а элемент сравнения реализуется на основе языковых сопоставлений. Это, очевидно, приводит к несогласованным результатам, например:

    Ordering.Double.compare(0.0, Double.NaN) == -1     // indicating 0.0 < NaN
    Ordering.Double.lt     (0.0, Double.NaN) == false  // contradiction
    
  • Каковы потенциальные недостатки BiasedOrdering, кроме прямой оценки любого противоречивого сравнения? Быстрая проверка sorted дала следующие результаты, которые не выявили никаких проблем:

    Ordering.Double             List(1.0, 2.0, 3.0, Double.NaN).sorted = List(1.0, 2.0, 3.0, NaN)
    OrderingDerivedFromCompare  List(1.0, 2.0, 3.0, Double.NaN).sorted = List(1.0, 2.0, 3.0, NaN)
    BiasedOrdering              List(1.0, 2.0, 3.0, Double.NaN).sorted = List(1.0, 2.0, 3.0, NaN)
    
    Ordering.Double             List(Double.NaN, 1.0, 2.0, 3.0).sorted = List(1.0, 2.0, 3.0, NaN)
    OrderingDerivedFromCompare  List(Double.NaN, 1.0, 2.0, 3.0).sorted = List(1.0, 2.0, 3.0, NaN)
    BiasedOrdering              List(Double.NaN, 1.0, 2.0, 3.0).sorted = List(1.0, 2.0, 3.0, NaN)
    

Пока я буду идти с этим левым предвзятым порядком. Но поскольку характер проблемы не позволяет безупречного общего решения: используйте с осторожностью!

Обновление

И с точки зрения решений, основанных на неявном классе, как предлагалось monkjack, мне очень нравится следующее (поскольку он вообще не воюет с (неполными) суммами заказов, а внутренне преобразуется в чистый полностью упорядоченный домен):

implicit class MinMaxNanAware(t: TraversableOnce[Double]) {
  def nanAwareMin = t.minBy(x => if (x.isNaN) Double.PositiveInfinity else x)
  def nanAwareMax = t.maxBy(x => if (x.isNaN) Double.NegativeInfinity else x)
}

// and now we can simply use
val goodMin = list.nanAwareMin

Ответ 2

Как насчет того, чтобы вводить косвенные в область действия, которые позволяли бы вам иметь новые методы min/max в списке.

Что-то вроде:

object NanAwareMinOrdering extends Ordering[Double] {
    def compare(x: Double, y: Double) = {
      if (x.isNaN()) {
        +1 // without checking x, return y < x
      } else if (y.isNaN()) {
        -1 // without checking y, return x < y
      } else {
        java.lang.Double.compare(x, y)
      }
    }
  }

object NanAwareMaxOrdering extends Ordering[Double] {
  ....
}

implicit class MinMaxList(list:List[Double]) {
  def min2 = list.min(NanAwareMinOrdering)
  def max2 = list.max(NanAwareMaxOrdering)
}

List(1.0, 2.0, 3.0, Double.NaN).min2

Ответ 3

Для

val a = List(1.0, 2.0, 3.0, Double.NaN)

сортировать,

a.sortWith {_ >_ }
res: List[Double] = List(3.0, 2.0, 1.0, NaN)

и поэтому значения NaN отклоняются, поэтому для max,

a.sortWith {_ >_ }.head
res: Double = 3.0

Аналогично

a.sortWith {_ < _ }
res: List[Double] = List(1.0, 2.0, 3.0, NaN)

и так для min,

a.sortWith {_ < _ }.head
res: Double = 1.0

Ответ 4

Этот ответ просто объясняет проблему, ответ @monkjack, вероятно, обеспечивает наилучшее практическое решение.

Теперь, поскольку Scala предлагает возможность неявно передать такой порядок, не является ли естественным желание передать заказ, который может справиться с "несравнимостью" в соответствии с нашими требованиями

Ordering в Scala представляет собой только полные упорядочения, т.е. те, где все элементы сопоставимы. Существует PartialOrdering[T]: http://www.scala-lang.org/api/2.10.3/index.html#scala.math.PartialOrdering, но есть несколько проблем:

  • Он фактически не используется нигде в стандартной библиотеке.

  • Если вы попытаетесь реализовать max/maxBy/etc. которые принимают PartialOrdering, вы быстро увидите, что это вообще не возможно, за исключением случаев, когда Float/Double, где у вас есть некоторые элементы, которые не сопоставимы ни с чем, а все остальное сопоставимы друг с другом (и вы можете просто игнорировать несравнимые элементы).