dongjoon-hyun commented on code in PR #59485:
URL: https://github.com/apache/spark/pull/59485#discussion_r4297407268
##########
mllib-local/src/main/scala/org/apache/spark/ml/linalg/Matrices.scala:
##########
@@ -1329,6 +1384,57 @@ object Matrices {
}
}
+ private[ml] def equals(left: Matrix, right: Matrix): Boolean = {
+ if (left.numRows != right.numRows || left.numCols != right.numCols) {
+ return false
+ }
+ (left, right) match {
+ case (leftSparse: SparseMatrix, rightSparse: SparseMatrix) =>
+ // Adapted from Breeze's CSCMatrix.equals. Retain its treatment of
explicit zeros:
+ // the last active entry is compared by coordinate even when its value
is zero.
+ val leftEntries = sparseEntries(leftSparse)
+ val rightEntries = sparseEntries(rightSparse)
+ while (leftEntries.hasNext && rightEntries.hasNext) {
+ var leftEntry = leftEntries.next()
+ var rightEntry = rightEntries.next()
+ while (leftEntry._3 == 0.0 && leftEntries.hasNext) {
+ leftEntry = leftEntries.next()
+ }
+ while (rightEntry._3 == 0.0 && rightEntries.hasNext) {
+ rightEntry = rightEntries.next()
+ }
+ if (leftEntry._1 != rightEntry._1 || leftEntry._2 != rightEntry._2 ||
+ leftEntry._3 != rightEntry._3) {
+ return false
+ }
+ }
+ leftEntries.forall(_._3 == 0.0) && rightEntries.forall(_._3 == 0.0)
+ case _ =>
+ var j = 0
+ while (j < left.numCols) {
+ var i = 0
+ while (i < left.numRows) {
+ if (left(i, j) != right(i, j)) {
Review Comment:
**[Low] Dense vs transposed sparse equality now binary-searches the raw CSR
arrays.**
The old code compared against `asBreeze` of the transposed matrix, which is
the sorted and merged copy built by Breeze's transpose, so `CSCMatrix.apply`
found every entry. Here `right(i, j)` goes to `SparseMatrix.index`, which runs
`Arrays.binarySearch` over the stored CSR column indices (L709). A row whose
column indices are unsorted or duplicated therefore gives false negatives. The
sparse/sparse branch above normalizes through `toSparseColMajor`, which sorts,
so the two branches now disagree and equality is not transitive.
For example:
- Take `t = new SparseMatrix(1, 3, Array(0, 2), Array(2, 0), Array(5.0,
7.0), isTransposed = true)` and `d = new DenseMatrix(1, 3, Array(7.0, 0.0,
5.0))`. `d == t` was true and is now false, because `t(0, 0)` misses in `[2,
0]` and returns 0.0. `t == t.toDense` also changes from true to false.
- Take `u = new SparseMatrix(1, 2, Array(0, 2), Array(1, 0), Array(1.0,
2.0), isTransposed = true)`, `s = new SparseMatrix(1, 2, Array(0, 1, 2),
Array(0, 0), Array(2.0, 1.0))` and `d2 = new DenseMatrix(1, 2, Array(2.0,
1.0))`. Now `u == s` and `s == d2` are true, but `u == d2` is false. All three
were true before.
Non-transposed CSC input already had the same miss before, so only
transposed operands changed. If parity matters here, could a transposed sparse
operand be normalized with `toSparseColMajor` before this element-wise loop?
That restores the old result.
##########
mllib-local/src/main/scala/org/apache/spark/ml/linalg/Matrices.scala:
##########
@@ -755,14 +779,45 @@ class SparseMatrix @Since("2.0.0") (
* order.
*/
private[ml] override def toSparseMatrix(colMajor: Boolean): SparseMatrix = {
- if (isColMajor && !colMajor) {
- // it is col major and we want row major, use breeze to remove explicit
zeros
- val breezeTransposed = asBreeze.asInstanceOf[BSM[Double]].t
-
Matrices.fromBreeze(breezeTransposed).transpose.asInstanceOf[SparseMatrix]
- } else if (isRowMajor && colMajor) {
- // it is row major and we want col major, use breeze to remove explicit
zeros
- val breezeTransposed = asBreeze.asInstanceOf[BSM[Double]]
- Matrices.fromBreeze(breezeTransposed).asInstanceOf[SparseMatrix]
+ if (isColMajor != colMajor) {
+ val numPtrs = if (colMajor) numCols else numRows
+ val newColPtrs = new Array[Int](numPtrs + 1)
+ var i = 0
+ while (i < values.length) {
+ if (values(i) != 0.0) {
+ newColPtrs(rowIndices(i) + 1) += 1
Review Comment:
**[Low] An out-of-range index now throws `ArrayIndexOutOfBoundsException`,
including from transposed `toString`, `hashCode` and `equals` that used to
succeed.**
Nothing checks `rowIndices(i)` before `newColPtrs(rowIndices(i) + 1)`. The
old Breeze builder checked the bound with a `require`
(`IllegalArgumentException`), but not for the first sorted entry. So a
transposed matrix whose only nonzero is out of range used to print and hash
fine.
For example, take `t = new SparseMatrix(1, 2, Array(0, 1), Array(5),
Array(1.0), isTransposed = true)`; the constructor does not check index ranges.
- `t.toString` was `1 x 2 CSCMatrix` and `t.hashCode` was 652125558. Both
now throw `ArrayIndexOutOfBoundsException: Index 6 out of bounds for length 3`,
and so does `t.equals` with another 1 x 2 `SparseMatrix`. `df.show()` on such a
row now fails as well.
- `new SparseMatrix(2, 2, Array(0, 1, 2), Array(0, 5), Array(1.0,
2.0)).toSparseRowMajor` used to throw `IllegalArgumentException: requirement
failed: Column index 5 is out of bounds for number of columns 2!`. It now
throws `ArrayIndexOutOfBoundsException: Index 6 ...`, whose index is one more
than the user's index.
- An index of -1 passes this line (`newColPtrs(0) += 1`) and fails later, at
L810.
Could the count loop check the bound, e.g. `require(idx >= 0 && idx <
numPtrs, ...)`, so that a malformed matrix fails with a clear message?
##########
mllib-local/src/main/scala/org/apache/spark/ml/linalg/Matrices.scala:
##########
@@ -755,14 +779,45 @@ class SparseMatrix @Since("2.0.0") (
* order.
*/
private[ml] override def toSparseMatrix(colMajor: Boolean): SparseMatrix = {
- if (isColMajor && !colMajor) {
- // it is col major and we want row major, use breeze to remove explicit
zeros
- val breezeTransposed = asBreeze.asInstanceOf[BSM[Double]].t
-
Matrices.fromBreeze(breezeTransposed).transpose.asInstanceOf[SparseMatrix]
- } else if (isRowMajor && colMajor) {
- // it is row major and we want col major, use breeze to remove explicit
zeros
- val breezeTransposed = asBreeze.asInstanceOf[BSM[Double]]
- Matrices.fromBreeze(breezeTransposed).asInstanceOf[SparseMatrix]
+ if (isColMajor != colMajor) {
+ val numPtrs = if (colMajor) numCols else numRows
+ val newColPtrs = new Array[Int](numPtrs + 1)
+ var i = 0
+ while (i < values.length) {
+ if (values(i) != 0.0) {
+ newColPtrs(rowIndices(i) + 1) += 1
+ }
+ i += 1
+ }
+ i = 0
+ while (i < numPtrs) {
+ newColPtrs(i + 1) += newColPtrs(i)
+ i += 1
+ }
+
+ val nnz = newColPtrs(numPtrs)
+ val newRowIndices = new Array[Int](nnz)
+ val newValues = new Array[Double](nnz)
+ val next = newColPtrs.clone()
+ var j = 0
+ // Visiting the original rows or columns in order keeps the new indices
sorted.
+ while (j < colPtrs.length - 1) {
+ var idx = colPtrs(j)
+ val idxEnd = colPtrs(j + 1)
+ while (idx < idxEnd) {
+ if (values(idx) != 0.0) {
Review Comment:
**[Low] Duplicate coordinates are no longer summed.**
The old conversion went through Breeze's `canTranspose_CSC` and
`CSCMatrix.Builder.result`, which sums entries with the same (row, column).
This counting sort keeps each duplicate as a separate entry. The constructor
does not reject duplicates (the Scaladoc only says that the indices "must be in
strictly increasing order"). So for such input, the result now differs from
Breeze, here and in the transposed `toString`, `hashCode` and `equals` that use
this conversion.
For example, take `t = new SparseMatrix(1, 2, Array(0, 2), Array(1, 1),
Array(1.0, 2.0), isTransposed = true)`.
- `t.toSparseColMajor` was `[0, 0, 1]` / `[0]` / `[3.0]`, and it is now `[0,
0, 2]` / `[0, 0]` / `[1.0, 2.0]`.
- `t.toString` printed `(0,1) 3.0`, and it now prints `(0,1) 1.0` and `(0,1)
2.0`.
- `t.hashCode` changes from -942016480 to -780899452.
- `t.equals(SparseMatrix.fromCOO(1, 2, Seq((0, 1, 3.0))))` changes from true
to false.
The converted matrix can also disagree with its source in BLAS. Take `a =
new SparseMatrix(1, 1, Array(0, 2), Array(0, 0), Array(1.0, 2.0))` and a
one-element `SparseVector` `x`. `a.multiply(x)` sums both entries (3 * x0), but
`a.toSparseRowMajor.multiply(x)` returns x0. The transposed sparse-sparse path
in `BLAS.gemv` moves on after the first match.
Is keeping duplicates intended? If exact Breeze parity is the goal, the
conversion could merge adjacent equal indices after the scatter. Otherwise,
could the PR description say that only canonical input keeps the Breeze
behavior?
##########
mllib-local/src/test/scala/org/apache/spark/ml/linalg/MatricesSuite.scala:
##########
@@ -105,6 +105,70 @@ class MatricesSuite extends SparkMLFunSuite {
assert(sm1 === dm2.transpose)
}
+ test("equals preserves Breeze floating-point and storage semantics") {
+ val values = Seq(0.0, -0.0, 1.0, Double.NaN,
+ Double.PositiveInfinity, Double.NegativeInfinity)
+ val matrices = values.flatMap { value =>
+ val dense = new DenseMatrix(2, 2, Array(0.0, value, 2.0, 0.0))
+ val sparse = new SparseMatrix(2, 2, Array(0, 1, 2), Array(1, 0),
Array(value, 2.0))
+ Seq(dense, dense.copy, sparse, sparse.copy, dense.transpose,
sparse.transpose)
+ } ++ Seq(
+ new DenseMatrix(0, 2, Array.emptyDoubleArray),
+ new DenseMatrix(2, 0, Array.emptyDoubleArray),
+ new SparseMatrix(0, 2, Array(0, 0, 0), Array.emptyIntArray,
Array.emptyDoubleArray),
+ new SparseMatrix(2, 0, Array(0), Array.emptyIntArray,
Array.emptyDoubleArray))
+
+ for (left <- matrices; right <- matrices) {
+ assert(left.equals(right) === left.asBreeze.equals(right.asBreeze))
Review Comment:
**[Low, tests] Most of the new coverage is parity with `asBreeze`, without
pinned values.**
The new tests compare with `asBreeze` at 11 places, about 2,000 checks at
runtime: here, L157, L1027, L1030 and L1048-1050, plus L149, L155, L552 and
L554 in the mllib suite. Only 16 assertions pin literal results. In particular,
every sparse hash value and the whole locale output are checked only against
Breeze. That is a strong differential test while Breeze is on the classpath.
However, the PR describes itself as a step toward reducing the Breeze
dependency, and once `asBreeze` goes away these checks would have to be deleted
or rewritten without the reference.
Could some expected values be pinned now, while Breeze 2.1.0 can still
generate them? For example, the six hash values in "sparse hashCode preserves
Breeze value hashing" are 1999056974 and -274380619 for `sparse` and its
transpose, -1538214276 and 2094930921 for `ordinary`, and 43 and 43 for
`empty`. The `ar-EG` sparse output could be a literal string. Also, mllib now
just delegates to `asML`, so the mllib tests could assert the delegation
directly, e.g. `m.toString(l, w) == m.asML.toString(l, w)` and `m.hashCode ==
m.asML.hashCode`.
##########
mllib-local/src/main/scala/org/apache/spark/ml/linalg/MatrixFormatter.scala:
##########
@@ -0,0 +1,115 @@
+// scalastyle:off license
+/*
+ Copyright 2012 David Hall
+
+ Licensed under the Apache License, Version 2.0 (the "License")
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+// scalastyle:on license
+
+package org.apache.spark.ml.linalg
+
+import scala.collection.mutable.ArrayBuffer
+
+/**
+ * Adapted from Matrix.scala and CSCMatrix.scala in Breeze 2.1.0 to use Spark
matrix storage,
+ * preserving the original layout and truncation rules.
+ */
+private[linalg] object MatrixFormatter {
+
+ def format(matrix: Matrix): String = {
+ // Breeze uses a fixed 80 x 80 terminal and reserves three lines for
sparse matrices.
+ val maxLines = if (matrix.isInstanceOf[SparseMatrix]) 77 else 80
+ format(matrix, maxLines, 80)
+ }
+
+ def format(matrix: Matrix, maxLines: Int, maxLineWidth: Int): String =
matrix match {
+ case sparse: SparseMatrix => formatSparse(sparse, maxLines)
+ case dense: DenseMatrix => formatDense(dense, maxLines, maxLineWidth)
+ }
+
+ private def formatSparse(matrix: SparseMatrix, maxLines: Int): String = {
+ // The previous Breeze transpose also reordered entries and removed
explicit zeros.
+ val columns = if (matrix.isTransposed) matrix.toSparseColMajor else matrix
+ val result = new StringBuilder("%d x %d CSCMatrix".format(matrix.numRows,
matrix.numCols))
+ val limit = math.min(columns.values.length, maxLines - 1)
Review Comment:
**[Medium] `formatSparse` walks `values` by position instead of by column
range.**
Breeze's `CSCMatrix.toString`, which the old `asBreeze.toString` used,
prints `activeIterator.take(maxLines - 1)`, and `activeIterator` walks the
`colPtrs(c) until colPtrs(c + 1)` ranges. This loop instead prints `values(0
until limit)` and derives each column from the pointers. That matches only when
`colPtrs(0) == 0` and `colPtrs` is monotone, and the constructor checks neither.
For example:
- `new SparseMatrix(3, 0, Array(2), Array(0, 1), Array(1.0, 2.0)).toString`
now throws `ArrayIndexOutOfBoundsException` at L48 (`columns.colPtrs(col +
1)`); the old output was `3 x 0 CSCMatrix`. `df.show()`, `CAST(m AS STRING)`
and `HiveResult` reach this through `UserDefinedType.stringifyValue`, which
`MatrixUDT` does not override, so a single such row fails the whole `show()`.
- `new SparseMatrix(1, 2, Array(1, 2, 3), Array(0, 0, 0), Array(9.0, 1.0,
2.0)).toString` now starts with an extra `(0,0) 9.0` line, for an entry that
`apply` and `toArray` never see.
- `new SparseMatrix(1, 3, Array(0, 1, 0, 1), Array(0), Array(5.0)).toString`
now omits `(0,2) 5.0`, although `apply(0, 2)` returns 5.0.
This PR already adds `Matrices.sparseEntries` (`Matrices.scala` L1428-1436),
which walks the ranges exactly like `activeIterator`. Could `formatSparse`
reuse it after widening it to `private[linalg]`, e.g.
`Matrices.sparseEntries(matrix).take(maxLines - 1).foreach { case (r, c, v) =>
... }`? For valid input the output stays the same, including `maxLines <= 0`
and the `Int.MinValue - 1` wrap. The PR would also keep only one copy of the
Breeze CSC traversal.
##########
NOTICE:
##########
@@ -1,6 +1,8 @@
Apache Spark
Copyright 2014 and onwards The Apache Software Foundation.
+Breeze is distributed under an Apache License V2.0 (See LICENSE)
Review Comment:
**[Low, licensing] This line splits the header block that must be at the top
of NOTICE.**
The ASF source header policy (https://www.apache.org/legal/src-headers.html)
says: "The top of each NOTICE file must include the following text". That text
is the product name and copyright line, followed by "This product includes
software developed at The Apache Software Foundation". Required third-party
notices go after that block. That is where the existing Metrics and JSR-166
entries are at the end of this file, and where `NOTICE-binary` L1052 has this
same Breeze sentence.
The sentence itself is right, since it is the first line of Breeze's own
NOTICE at `releases/v2.1.0`. Could it move below the ASF block, e.g. next to
the other third-party entries at the end? Also, the end-of-file newline changes
in `NOTICE` and `NOTICE-binary` look unrelated to this PR.
##########
mllib-local/src/main/scala/org/apache/spark/ml/linalg/Matrices.scala:
##########
@@ -755,14 +779,45 @@ class SparseMatrix @Since("2.0.0") (
* order.
*/
private[ml] override def toSparseMatrix(colMajor: Boolean): SparseMatrix = {
- if (isColMajor && !colMajor) {
- // it is col major and we want row major, use breeze to remove explicit
zeros
- val breezeTransposed = asBreeze.asInstanceOf[BSM[Double]].t
-
Matrices.fromBreeze(breezeTransposed).transpose.asInstanceOf[SparseMatrix]
- } else if (isRowMajor && colMajor) {
- // it is row major and we want col major, use breeze to remove explicit
zeros
- val breezeTransposed = asBreeze.asInstanceOf[BSM[Double]]
- Matrices.fromBreeze(breezeTransposed).asInstanceOf[SparseMatrix]
+ if (isColMajor != colMajor) {
+ val numPtrs = if (colMajor) numCols else numRows
+ val newColPtrs = new Array[Int](numPtrs + 1)
+ var i = 0
+ while (i < values.length) {
Review Comment:
**[Medium] The count pass and the fill pass visit different entries.**
This count loop walks all of `values` (L786-791), but the fill loop below
walks only the `colPtrs(j) until colPtrs(j + 1)` ranges (L804-818). The
`SparseMatrix` constructor only checks the array lengths and `values.length ==
colPtrs.last`, so it accepts `colPtrs(0) > 0` and non-monotone `colPtrs`. A
zero-copy view of the trailing columns, `colPtrs.slice(n - k, n + 1)` over the
shared `rowIndices` and `values`, is one example. `apply`, `foreachActive`,
`colIter` and BLAS all honor the ranges for such a matrix, and so did the old
Breeze transpose (`canTranspose_CSC`). Here, an entry outside the ranges is
counted but never written, so its slot keeps the default `(index 0, 0.0)`.
For example, `a = new SparseMatrix(1, 2, Array(1, 2, 3), Array(0, 0, 0),
Array(9.0, 1.0, 2.0))` has `a.toArray == Array(1.0, 2.0)`.
- The new `a.toSparseRowMajor` has rowPtrs `[0, 3]`, colIndices `[0, 1, 0]`
and values `[1.0, 2.0, 0.0]`. Its `toArray` is `[0.0, 2.0]`, because the
phantom `(0, 0) = 0.0` overwrites `1.0`. The old result was `[0, 2]` / `[0, 1]`
/ `[1.0, 2.0]`.
- For `new SparseMatrix(2, 1, Array(1, 3), Array(0, 0, 1), Array(1.0, 2.0,
3.0))`, `toSparseRowMajor.toDense` is `[0.0, 3.0]` instead of `[2.0, 3.0]`, and
the result's `colIter` throws `IllegalArgumentException` on the first `next()`.
- With non-monotone `colPtrs`, `new SparseMatrix(1, 3, Array(0, 1, 0, 1),
Array(0), Array(5.0)).toSparseRowMajor` throws `ArrayIndexOutOfBoundsException`
at L811, where the old code returned a CSR matrix.
Transposed matrices now go through this conversion in `toString`
(`MatrixFormatter.scala` L42), `hashCode` (L671) and `equals` (L1430), so they
inherit it. For example, `a.transpose.toString` prints an extra `(0,0) 0.0`
line, and with non-monotone `colPtrs` all three throw.
Could the count be done inside the same `colPtrs` range loop as the fill, so
that both passes see exactly the same entries?
##########
mllib-local/src/main/scala/org/apache/spark/ml/linalg/Matrices.scala:
##########
@@ -755,14 +779,45 @@ class SparseMatrix @Since("2.0.0") (
* order.
*/
private[ml] override def toSparseMatrix(colMajor: Boolean): SparseMatrix = {
- if (isColMajor && !colMajor) {
- // it is col major and we want row major, use breeze to remove explicit
zeros
- val breezeTransposed = asBreeze.asInstanceOf[BSM[Double]].t
-
Matrices.fromBreeze(breezeTransposed).transpose.asInstanceOf[SparseMatrix]
- } else if (isRowMajor && colMajor) {
- // it is row major and we want col major, use breeze to remove explicit
zeros
- val breezeTransposed = asBreeze.asInstanceOf[BSM[Double]]
- Matrices.fromBreeze(breezeTransposed).asInstanceOf[SparseMatrix]
+ if (isColMajor != colMajor) {
+ val numPtrs = if (colMajor) numCols else numRows
+ val newColPtrs = new Array[Int](numPtrs + 1)
Review Comment:
**[Low] Negative dimensions now behave differently.**
The constructors accept negative dimensions. `DenseMatrix` only checks
`values.length == numRows * numCols`, and a transposed `SparseMatrix` does not
check `numCols`. Breeze's constructors rejected them, and its transpose builder
inferred the size instead. As a result, the old code threw or returned a
resized result. The new code is silently lenient for dense matrices and throws
here for sparse ones.
For example:
- For `new DenseMatrix(-2, -3, new Array[Double](6))` (or
`DenseMatrix.zeros(-2, -3)`), `toString` and `equals` used to throw
`IndexOutOfBoundsException: Rows must be larger than zero. It was -2`. Now
`toString` returns `""`. `equals` compares no cells, so even `new
DenseMatrix(-1, -1, Array(1.0)) == new DenseMatrix(-1, -1, Array(2.0))` is true.
- For `SparseMatrix.fromCOO(-3, 1, Seq.empty).transpose`, `toString`,
`hashCode` and `equals` used to return `1 x 1 CSCMatrix`, 43 and true. Now all
three throw `NegativeArraySizeException` from `new Array[Int](numPtrs + 1)`
here.
This only affects degenerate input, so a note may be enough. A
`require(numRows >= 0 && numCols >= 0, ...)` in the constructors would make
both cases fail early and consistently, though that could be a separate JIRA.
##########
mllib-local/src/main/scala/org/apache/spark/ml/linalg/MatrixFormatter.scala:
##########
@@ -0,0 +1,115 @@
+// scalastyle:off license
+/*
+ Copyright 2012 David Hall
+
+ Licensed under the Apache License, Version 2.0 (the "License")
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+// scalastyle:on license
+
+package org.apache.spark.ml.linalg
+
+import scala.collection.mutable.ArrayBuffer
+
+/**
+ * Adapted from Matrix.scala and CSCMatrix.scala in Breeze 2.1.0 to use Spark
matrix storage,
+ * preserving the original layout and truncation rules.
+ */
+private[linalg] object MatrixFormatter {
+
+ def format(matrix: Matrix): String = {
+ // Breeze uses a fixed 80 x 80 terminal and reserves three lines for
sparse matrices.
+ val maxLines = if (matrix.isInstanceOf[SparseMatrix]) 77 else 80
+ format(matrix, maxLines, 80)
+ }
+
+ def format(matrix: Matrix, maxLines: Int, maxLineWidth: Int): String =
matrix match {
+ case sparse: SparseMatrix => formatSparse(sparse, maxLines)
+ case dense: DenseMatrix => formatDense(dense, maxLines, maxLineWidth)
+ }
+
+ private def formatSparse(matrix: SparseMatrix, maxLines: Int): String = {
+ // The previous Breeze transpose also reordered entries and removed
explicit zeros.
+ val columns = if (matrix.isTransposed) matrix.toSparseColMajor else matrix
+ val result = new StringBuilder("%d x %d CSCMatrix".format(matrix.numRows,
matrix.numCols))
+ val limit = math.min(columns.values.length, maxLines - 1)
+ var col = 0
+ var index = 0
+ while (index < limit) {
+ while (index >= columns.colPtrs(col + 1)) {
+ col += 1
+ }
+ result.append('\n').append("(%d,%d) ".format(columns.rowIndices(index),
col))
+ .append(columns.values(index))
+ index += 1
+ }
+ result.toString()
+ }
+
+ private def formatDense(matrix: DenseMatrix, maxLines: Int, maxLineWidth:
Int): String = {
+ val rows = matrix.numRows
+ val cols = matrix.numCols
+ val showRows = if (rows > maxLines) maxLines - 1 else rows
+ val widths = new ArrayBuffer[Int]
+ var totalWidth = 0
+ var col = 0
+ while (col < cols && totalWidth < maxLineWidth) {
+ var width = 0
+ var row = 0
+ while (row < showRows) {
+ width = math.max(width, matrix(row, col).toString.length + 2)
Review Comment:
**[Nit] `toString(Int.MinValue, width)` now throws a different exception.**
With `maxLines = Int.MinValue`, `showRows` wraps to `Int.MaxValue` at L61,
exactly as in Breeze, so this loop reads past the last row. Breeze's
`DenseMatrix.apply` threw `IndexOutOfBoundsException`. Spark's
`DenseMatrix.apply` throws `IllegalArgumentException` from the `require` in
`index`.
For example, `Matrices.rand(5, 10, rng).toString(Int.MinValue, 80)` used to
throw `IndexOutOfBoundsException: (5,0) not in [-5,5) x [-10,10)`. It now
throws `IllegalArgumentException: requirement failed: Expected 0 <= i < 5, got
i = 5.`
The call already failed before, so this only affects the exact-parity claim.
The existing "toString" test calls `toString(Int.MinValue, Int.MinValue)`,
which returns `5 x 10 matrix` before it reads any cell, so this path is not
covered. Feel free to ignore it if exception parity is not a goal.
##########
mllib-local/src/test/scala/org/apache/spark/ml/linalg/MatricesSuite.scala:
##########
@@ -870,6 +1009,57 @@ class MatricesSuite extends SparkMLFunSuite {
assert(lines.length == 5 && lines.forall(_.length <= 100))
}
+ test("matrix formatting preserves Breeze layout and truncation") {
+ val dense = new DenseMatrix(3, 4,
+ Array(0.0, -0.0, 1.0, 12.0, -123.0, 1e100, Double.NaN,
+ Double.PositiveInfinity, Double.NegativeInfinity, 0.01, 0.0, 3.0))
+ val sparse = new SparseMatrix(3, 4, Array(0, 2, 2, 4, 5),
+ Array(0, 2, 0, 1, 2), Array(-0.0, 1.0, Double.NaN, 0.0,
Double.PositiveInfinity))
+ val tall = new DenseMatrix(85, 1, Array.tabulate(85)(_.toDouble))
+ val wide = DenseMatrix.ones(2, 30)
+ val matrices = Seq(dense, sparse, dense.transpose, sparse.transpose,
+ tall, tall.toSparse, wide, DenseMatrix.zeros(0, 0), DenseMatrix.zeros(0,
3),
+ DenseMatrix.zeros(3, 0), SparseMatrix.fromCOO(0, 3, Seq.empty),
+ SparseMatrix.fromCOO(3, 0, Seq.empty))
+ val limits = Seq((-1, -5), (0, 0), (1, 1), (2, 15), (3, 25), (5, 50),
+ (80, 80), (Int.MaxValue, Int.MaxValue))
+ matrices.foreach { matrix =>
+ assert(matrix.toString == matrix.asBreeze.toString())
+ limits.foreach { case (lines, width) =>
+ withClue(s"${matrix.numRows} x ${matrix.numCols}, limits ($lines,
$width): ") {
+ assert(matrix.toString(lines, width) ==
matrix.asBreeze.toString(lines, width))
+ }
+ }
+ }
+ val expectedWideRow = "1.0 " * 13
+ assert(wide.toString == s"$expectedWideRow... (30
total)\n$expectedWideRow...")
+ assert(new DenseMatrix(2, 2, Array(1.0, 20.0, 3.0, 4.0)).toString(10, 80)
==
+ "1.0 3.0 \n20.0 4.0 ")
+ assert(new SparseMatrix(2, 2, Array(0, 1, 2), Array(1, 0), Array(2.0, 3.0))
+ .toString(2, 1) == "2 x 2 CSCMatrix\n(1,0) 2.0")
+ }
+
+ test("matrix formatting preserves the default formatting locale") {
+ val originalLocale = Locale.getDefault(Locale.Category.FORMAT)
+ try {
+ Locale.setDefault(Locale.Category.FORMAT, Locale.forLanguageTag("ar-EG"))
+ val dense = new DenseMatrix(2, 2, Array(1.0, 2.0, 3.0, 4.0))
+ val sparse = dense.toSparse
+ assert(dense.toString(1, 1) == dense.asBreeze.toString(1, 1))
Review Comment:
**[Low, tests] The dense assertion of this locale test never reaches the
locale-sensitive code.**
For this 2 x 2 matrix, `toString(1, 1)` sets `showRows = 0`, so every column
width is 0 and all columns fit. Both implementations return `"\n... (2
total)"`, which is built by interpolation and appends. The only
locale-sensitive dense path, `"%d x %d matrix".format(rows, cols)` at
`MatrixFormatter.scala` L82, never runs. If L82 were changed to `s"$rows x
$cols matrix"`, this test would still pass. So would every other test, because
CI uses a default locale with Latin digits.
`dense.toString(5, 1)` (or `toString(2, 5)`) reaches L82 and returns `2 x 2
matrix` with Arabic-Indic digits under `ar-EG`, in Breeze and here. Could the
dense assertion use one of those limits? The two sparse assertions do go
through `.format`.
##########
mllib-local/src/main/scala/org/apache/spark/ml/linalg/Vectors.scala:
##########
@@ -104,7 +104,10 @@ sealed trait Vector extends Serializable {
* @param i index
*/
@Since("2.0.0")
- def apply(i: Int): Double = asBreeze(i)
+ def apply(i: Int): Double = this match {
Review Comment:
**[Low, cleanup] This trait body cannot run, and it would recurse if an
override were removed.**
`Vector` is sealed, and both `DenseVector` (L539) and `SparseVector` (L680)
override `apply`, so this body is never executed. The old `asBreeze(i)` body
was never executed either. The same holds for the mllib `Vector` (mllib
`Vectors.scala` L115-118, with overrides at L681 and L833). So "Dispatch vector
element access to Spark's dense and sparse implementations" in the PR
description is not a runtime change.
Each case calls the same virtual `apply` again. Suppose a later refactor
removes one of the overrides, e.g. while removing Breeze. Then this still
compiles without a warning, and `v(i)` throws `StackOverflowError`. By
contrast, the old body fell back to Breeze, and an abstract method would fail
to compile.
Could the body avoid re-dispatching? For example, it could throw
`UnsupportedOperationException` like `copy` in the same trait, or keep
`asBreeze(i)` for now. Making `apply` abstract would also work, but it needs a
MiMa exclusion (`DirectMissingMethodProblem` for `Vector.apply` in both
packages).
##########
mllib-local/src/main/scala/org/apache/spark/ml/linalg/Matrices.scala:
##########
@@ -755,14 +779,45 @@ class SparseMatrix @Since("2.0.0") (
* order.
*/
private[ml] override def toSparseMatrix(colMajor: Boolean): SparseMatrix = {
- if (isColMajor && !colMajor) {
- // it is col major and we want row major, use breeze to remove explicit
zeros
- val breezeTransposed = asBreeze.asInstanceOf[BSM[Double]].t
-
Matrices.fromBreeze(breezeTransposed).transpose.asInstanceOf[SparseMatrix]
- } else if (isRowMajor && colMajor) {
- // it is row major and we want col major, use breeze to remove explicit
zeros
- val breezeTransposed = asBreeze.asInstanceOf[BSM[Double]]
- Matrices.fromBreeze(breezeTransposed).asInstanceOf[SparseMatrix]
+ if (isColMajor != colMajor) {
+ val numPtrs = if (colMajor) numCols else numRows
+ val newColPtrs = new Array[Int](numPtrs + 1)
+ var i = 0
+ while (i < values.length) {
+ if (values(i) != 0.0) {
+ newColPtrs(rowIndices(i) + 1) += 1
+ }
+ i += 1
+ }
+ i = 0
+ while (i < numPtrs) {
+ newColPtrs(i + 1) += newColPtrs(i)
+ i += 1
+ }
+
+ val nnz = newColPtrs(numPtrs)
+ val newRowIndices = new Array[Int](nnz)
+ val newValues = new Array[Double](nnz)
+ val next = newColPtrs.clone()
Review Comment:
**[Low, performance] `next` doubles the pointer-array allocation for wide,
very sparse matrices.**
This clone allocates a second `Int` array of `numPtrs + 1` elements. The old
Breeze transpose allocated one array of that size (`outCols` in
`Builder.result`) plus O(nnz) arrays. So when `numPtrs` is much larger than
nnz, this conversion allocates about twice as much, and so do the transposed
`toString`, `hashCode` and `equals` that use it. For example:
- The new 100000 x 100000 test matrix with 2 entries: about 400 KB before,
800 KB now.
- A 3 x 50,000,000 CSR matrix with 2 entries: about 200 MB before, 400 MB
now.
The ratio approaches 2 once `numPtrs` is above about 1000 x nnz. Below about
70 x nnz, this code already allocates less than Breeze. The realistic case is a
wide CSR coefficient matrix from `compressed`, e.g. a multinomial
`LogisticRegression` model trained with L1.
Could the cursor reuse `newColPtrs` without a copy, similar to scipy's
`csr_tocsc`? Keep the inclusive prefix sums, use `newColPtrs(newCol)` as the
write cursor and increment it. Then shift the array right by one and set
`newColPtrs(0) = 0`. I checked that this produces the same arrays as Breeze's
transpose on 30,000 random valid matrices.
##########
mllib-local/src/test/scala/org/apache/spark/ml/linalg/MatricesSuite.scala:
##########
@@ -105,6 +105,70 @@ class MatricesSuite extends SparkMLFunSuite {
assert(sm1 === dm2.transpose)
}
+ test("equals preserves Breeze floating-point and storage semantics") {
+ val values = Seq(0.0, -0.0, 1.0, Double.NaN,
+ Double.PositiveInfinity, Double.NegativeInfinity)
+ val matrices = values.flatMap { value =>
+ val dense = new DenseMatrix(2, 2, Array(0.0, value, 2.0, 0.0))
+ val sparse = new SparseMatrix(2, 2, Array(0, 1, 2), Array(1, 0),
Array(value, 2.0))
+ Seq(dense, dense.copy, sparse, sparse.copy, dense.transpose,
sparse.transpose)
+ } ++ Seq(
+ new DenseMatrix(0, 2, Array.emptyDoubleArray),
+ new DenseMatrix(2, 0, Array.emptyDoubleArray),
+ new SparseMatrix(0, 2, Array(0, 0, 0), Array.emptyIntArray,
Array.emptyDoubleArray),
+ new SparseMatrix(2, 0, Array(0), Array.emptyIntArray,
Array.emptyDoubleArray))
+
+ for (left <- matrices; right <- matrices) {
+ assert(left.equals(right) === left.asBreeze.equals(right.asBreeze))
+ }
+ matrices.foreach { matrix =>
+ assert(!matrix.equals(null))
+ assert(!matrix.equals(matrix.toArray))
+ }
+ }
+
+ test("sparse equality preserves explicit-zero compatibility") {
+ val empty = new SparseMatrix(3, 1, Array(0, 0), Array.emptyIntArray,
Array.emptyDoubleArray)
+ val zeroAtFirst = new SparseMatrix(3, 1, Array(0, 1), Array(0), Array(0.0))
+ val zeroAtLast = new SparseMatrix(3, 1, Array(0, 1), Array(2), Array(-0.0))
+ assert(zeroAtFirst.equals(empty))
+ assert(zeroAtLast.equals(empty))
+ assert(!zeroAtFirst.equals(zeroAtLast))
Review Comment:
**[Low, tests] This pins a non-transitive equality as Spark behavior.**
`zeroAtFirst == empty` and `zeroAtLast == empty` hold (L134-135), but
`zeroAtFirst != zeroAtLast` here, so `equals` is not transitive.
- The same two matrices are equal after `transpose`, `toSparse` or `toDense`.
- Their single `colIter` columns are equal as vectors, because
`Vectors.equals` skips zeros.
- They have the same `hashCode` (43).
Valid matrices get such trailing explicit zeros too, e.g. after `update(i,
j, 0.0)` or `map`. So the result of `distinct` or a `HashSet` can depend on
where explicit zeros are stored.
I understand that the PR keeps Breeze's behavior on purpose, as the comment
at `Matrices.scala` L1393-1394 says, and that changing it would change
behavior. However, once these assertions (here and L144) pin it, a later fix
has to invert them. Could the test and the comment call it a known Breeze quirk
kept for compatibility, rather than the intended semantics? A separate JIRA
could then make sparse equality skip zeros like `Vectors.equals`.
##########
mllib-local/src/main/scala/org/apache/spark/ml/linalg/MatrixFormatter.scala:
##########
@@ -0,0 +1,115 @@
+// scalastyle:off license
+/*
+ Copyright 2012 David Hall
+
+ Licensed under the Apache License, Version 2.0 (the "License")
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+// scalastyle:on license
+
+package org.apache.spark.ml.linalg
+
+import scala.collection.mutable.ArrayBuffer
+
+/**
+ * Adapted from Matrix.scala and CSCMatrix.scala in Breeze 2.1.0 to use Spark
matrix storage,
+ * preserving the original layout and truncation rules.
+ */
+private[linalg] object MatrixFormatter {
+
+ def format(matrix: Matrix): String = {
+ // Breeze uses a fixed 80 x 80 terminal and reserves three lines for
sparse matrices.
+ val maxLines = if (matrix.isInstanceOf[SparseMatrix]) 77 else 80
+ format(matrix, maxLines, 80)
+ }
+
+ def format(matrix: Matrix, maxLines: Int, maxLineWidth: Int): String =
matrix match {
+ case sparse: SparseMatrix => formatSparse(sparse, maxLines)
+ case dense: DenseMatrix => formatDense(dense, maxLines, maxLineWidth)
+ }
+
+ private def formatSparse(matrix: SparseMatrix, maxLines: Int): String = {
+ // The previous Breeze transpose also reordered entries and removed
explicit zeros.
+ val columns = if (matrix.isTransposed) matrix.toSparseColMajor else matrix
Review Comment:
**[Low, performance] A transposed matrix is fully converted on every
`toString`, `hashCode` and `equals` call.**
For a CSR matrix, `toString` converts the whole matrix to CSC here, in O(nnz
+ numRows + numCols) time with four new arrays, only to print at most `maxLines
- 1` entries (76 by default).
- `SparseMatrix.hashCode` (`Matrices.scala` L671) builds a full
`SparseMatrix` only to read `values` in column-major order.
- `equals` (L1430) converts each transposed operand, then allocates an
iterator per column and a boxed `Tuple3` per entry in `sparseEntries`.
A realistic case is printing a wide L1 multinomial `LogisticRegression`
coefficient matrix, which `compressed` stores as CSR, or calling `df.show()` on
it. The bundled examples print `coefficientMatrix`.
Breeze also transposed everything on each call, so this is a missed
improvement rather than a regression. Two smaller steps could still help:
- A single helper for the "convert if transposed" view. It is now repeated
in three places with three different comments: here, L671 and L1430.
- An index-based loop instead of the
`Iterator.range(...).flatMap(...).map(...)` chain in `sparseEntries`.
##########
LICENSE:
##########
@@ -217,6 +217,8 @@ core/src/main/resources/org/apache/spark/ui/static/vis*
connector/spark-ganglia-lgpl/src/main/java/com/codahale/metrics/ganglia/GangliaReporter.java
core/src/main/resources/org/apache/spark/ui/static/d3-flamegraph.min.js
core/src/main/resources/org/apache/spark/ui/static/d3-flamegraph.css
+mllib-local/src/main/scala/org/apache/spark/ml/linalg/Matrices.scala (portions)
Review Comment:
**[Low, licensing] `Matrices.scala (portions)` introduces a new, hybrid
attribution model.**
This is the only partial-file entry in `LICENSE` and `LICENSE-binary`. It
also makes `Matrices.scala` the only source file that carries both the ASF
header and a third-party copyright block (L18-25).
Spark's existing conventions are different:
- Within the same PR, `MatrixFormatter.scala` follows the usual per-file
convention, the one HyukjinKwon's earlier review pointed to: the upstream
header plus a `LICENSE` entry, like `TimSort.java` and
`scala/collection/compat/package.scala`.
- For small snippets inside ASF files, Spark otherwise uses only an inline
"based on" comment, e.g. `Murmur3_x86_32.java` L25 and `XXH64.java` L29.
The adapted lines (L671-681 and L1393-1411) do have inline markers. However,
it is unclear whether the dense `case _` branch (L1412-1424, which follows
Breeze's `Matrix.equals`) is covered. When this code is rewritten later, the
header block and the two `LICENSE` lines can easily go stale.
Could `Matrices.equals`, `sparseEntries` and the sparse hash loop move into
a Breeze-headed `private[linalg]` object like `MatrixFormatter`? Then the
attribution stays per file, and `DenseMatrix.equals`, `SparseMatrix.equals` and
`SparseMatrix.hashCode` become one-line delegations.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]