From 95820646f2fb02c2b54b6cfbafb15825c0c7d744 Mon Sep 17 00:00:00 2001 From: kary zheng Date: Wed, 26 Aug 2026 16:35:16 -0700 Subject: [PATCH] feat(sklearn): refuse the label column as an input column The Target Attribute could also be named as a Text Attribute, and the Ground Truth Attribute Column as a Selected Feature. The first ends the run from inside scikit-learn naming neither field, and the second does not fail at all: the estimator is handed the answer as an input. Both are refused while the workflow is being written, beside the Count Vectorizer refusal the shared base already carries. Co-Authored-By: Claude Opus 5 (1M context) --- .../base/SklearnAdvancedBaseDesc.scala | 11 ++++++++ .../operator/sklearn/SklearnModelOpDesc.scala | 10 +++++++ .../SklearnMLOperatorDescriptorSpec.scala | 17 +++++++++++ .../sklearn/SklearnModelOpDescSpec.scala | 28 +++++++++++++++++++ 4 files changed, 66 insertions(+) diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnAdvancedBaseDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnAdvancedBaseDesc.scala index 3127fa91232..7817c4ec390 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnAdvancedBaseDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnAdvancedBaseDesc.scala @@ -163,6 +163,17 @@ abstract class SklearnMLOperatorDescriptor[T <: ParamClass] extends PythonOperat override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] ): Map[PortIdentity, Schema] = { + // The features go to `fit` as they are named, so the ground truth among them + // is the answer handed to the estimator as an input. Nothing fails when that + // happens, which is why it is refused here: the run finishes and the model + // scores far better than what it learned deserves. + if (Option(selectedFeatures).exists(_.contains(groundTruthAttribute))) { + throw new RuntimeException( + s""""$groundTruthAttribute" is the Ground Truth Attribute Column, so it cannot""" + + " also be a Selected Feature. Remove it from Selected Features, or fit against" + + " a different column." + ) + } val outputSchema = Schema( List( new Attribute("Model", AttributeType.BINARY), diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnModelOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnModelOpDesc.scala index fc834659e4d..7c76778aa58 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnModelOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnModelOpDesc.scala @@ -137,6 +137,16 @@ abstract class SklearnModelOpDesc extends PythonOperatorDescriptor { s" produces. Turn Count Vectorizer off, or use $alternatives." ) } + // The generated code drops the target before the text pipeline reads its + // columns, so naming it here asks the pipeline for a column that is no + // longer there. Refused rather than vectorized: the label is the answer, + // and a model given it as a feature reads that answer off its own input. + if (text.contains(target)) { + throw new RuntimeException( + s""""$target" is the Target Attribute, so it cannot also be a Text Attribute.""" + + " Remove it from Text Attribute, or fit against a different column." + ) + } } Map( operatorInfo.outputPorts.head.id -> Schema() diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnMLOperatorDescriptorSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnMLOperatorDescriptorSpec.scala index eac74a54d6c..a4411b56970 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnMLOperatorDescriptorSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnMLOperatorDescriptorSpec.scala @@ -62,6 +62,23 @@ class SklearnMLOperatorDescriptorSpec extends AnyFlatSpec with Matchers { ) } + it should "reject the ground truth column named as a feature" in { + val op = newOp() + op.groundTruthAttribute = "species" + op.selectedFeatures = List("petal_length", "species") + val thrown = intercept[RuntimeException](op.getOutputSchemas(Map.empty)) + thrown.getMessage should include("species") + thrown.getMessage should include("Ground Truth Attribute Column") + thrown.getMessage should include("Selected Features") + } + + it should "let the features through while none of them is the ground truth" in { + val op = newOp() + op.groundTruthAttribute = "species" + op.selectedFeatures = List("petal_length", "petal_width") + op.getOutputSchemas(Map.empty).keySet shouldBe Set(op.operatorInfo.outputPorts.head.id) + } + "SklearnMLOperatorDescriptor" should "default paraList to empty, groundTruthAttribute to empty, and selectedFeatures to null" in { val op = newOp() diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/sklearn/SklearnModelOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/sklearn/SklearnModelOpDescSpec.scala index 1bf1ca4c4fa..2e72d4fe0d1 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/sklearn/SklearnModelOpDescSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/sklearn/SklearnModelOpDescSpec.scala @@ -103,4 +103,32 @@ class SklearnModelOpDescSpec extends AnyFlatSpec with Matchers { } d.getOutputSchemas(Map.empty).keySet shouldBe Set(d.operatorInfo.outputPorts.head.id) } + + it should "reject the target named as a text column" in { + val d = new TestSklearnModelOpDesc + d.countVectorizer = true + d.target = "species" + d.text = List("note", "species") + val thrown = intercept[RuntimeException](d.getOutputSchemas(Map.empty)) + thrown.getMessage should include("species") + thrown.getMessage should include("Target Attribute") + thrown.getMessage should include("Text Attribute") + } + + it should "let the text columns through while none of them is the target" in { + val d = new TestSklearnModelOpDesc + d.countVectorizer = true + d.target = "species" + d.text = List("note") + d.getOutputSchemas(Map.empty).keySet shouldBe Set(d.operatorInfo.outputPorts.head.id) + } + + it should "leave a stale text column alone while Count Vectorizer is off" in { + // Nothing reads `text` with the switch off, and the panel hides it, so a value + // left behind by an earlier configuration must not report the operator invalid. + val d = new TestSklearnModelOpDesc + d.target = "species" + d.text = List("species") + d.getOutputSchemas(Map.empty).keySet shouldBe Set(d.operatorInfo.outputPorts.head.id) + } }