Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,36 @@ import org.apache.texera.amber.operator.PythonOperatorDescriptor
import org.apache.texera.amber.operator.metadata.annotations.{AutofillAttributeName, HideAnnotation}
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo}

// Whichever metric list the task shows must hold at least one metric: an empty
// one renders as `metric_list = ['']` and the metric lookup fails on the empty
// name. The rule is conditional because only one of the two lists is visible at
// a time, and a flat `required` on both can never be satisfied.
@JsonSchemaInject(json = """
{
"allOf": [
{
"if": {
"required": ["isRegression"],
"properties": { "isRegression": { "const": false } }
},
"then": {
"required": ["classificationFlag"],
"properties": { "classificationFlag": { "minItems": 1 } }
}
},
{
"if": {
"required": ["isRegression"],
"properties": { "isRegression": { "const": true } }
},
"then": {
"required": ["regressionFlag"],
"properties": { "regressionFlag": { "minItems": 1 } }
}
}
]
}
""")
class MachineLearningScorerOpDesc extends PythonOperatorDescriptor {
@JsonProperty(required = true, defaultValue = "false")
@JsonSchemaTitle("Regression")
Expand Down Expand Up @@ -85,9 +115,61 @@ class MachineLearningScorerOpDesc extends PythonOperatorDescriptor {
inputPorts = List(InputPort()),
outputPorts = List(OutputPort())
)

/** The two scored columns hold one quantity measured twice, so they have to be
* comparable. Left to sklearn, a mismatched pair either raises `Mix of label
* input types` from deep inside a metric or, for Accuracy, quietly scores 0 —
* neither of which points back at the two columns the user picked.
*
* Types only, not values: a DOUBLE label column of 0.0 / 1.0 is a legitimate
* classification target even though a continuous one is not, and the schema
* cannot tell the two apart.
*/
private def validateScoredColumns(inputSchemas: Map[PortIdentity, Schema]): Unit = {
val numeric = Set(AttributeType.INTEGER, AttributeType.LONG, AttributeType.DOUBLE)
// Blank names are what the form reports before the user has picked; the
// schema's own `required` already says so, and saying it twice would put a
// second message on a half-filled operator.
if (actualValueColumn.isEmpty || predictValueColumn.isEmpty) return
inputSchemas.get(operatorInfo.inputPorts.head.id).foreach { schema =>
Seq(("Actual Value", actualValueColumn), ("Predicted Value", predictValueColumn))
.foreach {
case (label, column) =>
if (!schema.containsAttribute(column))
throw new RuntimeException(s"$label column '$column' is not in the input table")
}
val actualType = schema.getAttribute(actualValueColumn).getType
val predictType = schema.getAttribute(predictValueColumn).getType
if (isRegression) {
Seq(
("Actual Value", actualValueColumn, actualType),
("Predicted Value", predictValueColumn, predictType)
)
.foreach {
case (label, column, attributeType) =>
if (!numeric.contains(attributeType))
throw new RuntimeException(
s"A regression metric needs a numeric $label column, but '$column' is " +
s"${attributeType.getName}"
)
}
} else {
val comparable = (numeric.contains(actualType) && numeric.contains(predictType)) ||
actualType == predictType
if (!comparable)
throw new RuntimeException(
s"Actual Value '$actualValueColumn' (${actualType.getName}) and Predicted Value " +
s"'$predictValueColumn' (${predictType.getName}) hold different kinds of label, " +
"so a classification metric cannot compare them"
)
}
}
}

override def getOutputSchemas(
inputSchemas: Map[PortIdentity, Schema]
): Map[PortIdentity, Schema] = {
validateScoredColumns(inputSchemas)
val metrics = if (isRegression) {
regressionMetrics.map(_.getName())
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package org.apache.texera.amber.operator.machineLearning.Scorer

import com.fasterxml.jackson.databind.node.ObjectNode
import org.apache.texera.amber.core.tuple.{Attribute, AttributeType, Schema}
import org.apache.texera.amber.core.workflow.PortIdentity
import org.apache.texera.amber.operator.LogicalOp
import org.apache.texera.amber.operator.metadata.OperatorGroupConstants
import org.apache.texera.amber.util.JSONUtils.objectMapper
Expand Down Expand Up @@ -109,6 +110,67 @@ class MachineLearningScorerOpDescSpec extends AnyFlatSpec with Matchers {
List(("MSE", AttributeType.DOUBLE), ("R2", AttributeType.DOUBLE))
}

/** An input table holding one column of each type the scored-column check reasons about. */
private def inputSchemas(d: MachineLearningScorerOpDesc): Map[PortIdentity, Schema] =
Map(
d.operatorInfo.inputPorts.head.id -> Schema(
List(
new Attribute("y_int", AttributeType.INTEGER),
new Attribute("pred_long", AttributeType.LONG),
new Attribute("label_str", AttributeType.STRING),
new Attribute("pred_str", AttributeType.STRING)
)
)
)

private def scorer(
regression: Boolean,
actual: String,
predict: String
): MachineLearningScorerOpDesc = {
val d = new MachineLearningScorerOpDesc
d.isRegression = regression
d.actualValueColumn = actual
d.predictValueColumn = predict
d
}

it should "accept a classification pair whose types are both numeric" in {
// INTEGER against LONG is one label domain read through two widths, which is
// what an upstream predictor emitting a wider column looks like.
val d = scorer(regression = false, "y_int", "pred_long")
d.classificationMetrics = List(classificationMetricsFnc.accuracy)
noException should be thrownBy d.getOutputSchemas(inputSchemas(d))
}

it should "reject a classification pair that mixes a number with a string" in {
val d = scorer(regression = false, "y_int", "pred_str")
d.classificationMetrics = List(classificationMetricsFnc.accuracy)
the[RuntimeException] thrownBy d.getOutputSchemas(inputSchemas(d)) should have message
"Actual Value 'y_int' (integer) and Predicted Value 'pred_str' (string) hold different " +
"kinds of label, so a classification metric cannot compare them"
}

it should "reject a non-numeric column once the task is regression" in {
val d = scorer(regression = true, "label_str", "pred_str")
d.regressionMetrics = List(regressionMetricsFnc.mse)
the[RuntimeException] thrownBy d.getOutputSchemas(inputSchemas(d)) should have message
"A regression metric needs a numeric Actual Value column, but 'label_str' is string"
}

it should "reject a column the input table does not hold" in {
val d = scorer(regression = false, "y_int", "absent")
the[RuntimeException] thrownBy d.getOutputSchemas(inputSchemas(d)) should have message
"Predicted Value column 'absent' is not in the input table"
}

it should "say nothing about the pair while a column is still unpicked" in {
// Half-filled is the state every operator passes through on the way to a
// valid one; the empty field already carries its own required marker.
val d = scorer(regression = false, "y_int", "")
noException should be thrownBy d.getOutputSchemas(inputSchemas(d))
}

"MachineLearningScorerOpDesc.generatePythonCode" should "emit the scorer table operator" in {
val d = new MachineLearningScorerOpDesc
d.actualValueColumn = "y"
Expand Down
Loading