F-bounded polymorphism, path-dependent types.
In this method, we have Auditor
s and MorphableAuditor
s. Auditors are
inputs to models and are responsible for producing the
audit trail that is returned as the
output of a model's prediction function. Morphable auditors are auditors that
can produce new auditors with the same structure but with different type
parameters.
trait Auditor[K, A, +B] {
type AuditOutput[_] // B = AuditOutput[A]
private[aloha] def failure[S](...): B // model fails to predict
private[aloha] def success[S](...): B // model successfully predicts
}
// Notice Impl is a self type describing the implementation.
trait MorphableAuditor[K, A, +B, Impl <: MorphableAuditor[K, A, B, Impl]]
extends Auditor[K, A, B] {
def auditor[S: RefInfo]: Option[Auditor[K, S, Impl#AuditOutput[S]]]
}
Models are just functions. All Model
instances also must also extends a more decorated interface called AuditedModel
, which contains an Auditor
. Notice
audited models have a "natural output type" N
and an actual output B
where the relationship between the two types is B = auditor.AuditOutput[N]
.
trait Model[-A, +B] extends (A => B) { self: AuditedModel[A, _, B] => }
trait AuditedModel[-A, N, +B] extends Model[A, B] {
def auditor: Auditor[ModelIdentity, N, B]
}
So a constant model will have three type parameters: A,
N, and
B, with the normal relationship between
N, and
B`. This works fine but we have an extra type parameter.
case class ConstantModel[-A, N, +B](
modelId: ModelIdentity,
constant: N,
auditor: Auditor[ModelIdentity, N, B])
extends AuditedModel[A, N, B] {
def apply(a: A): B = auditor.success(modelId, constant)
}
This method works much better when instantiating model factories and directly instantiating models in Java. For instance:
@SuppressWarnings("unchecked")
import deaktator.reflect.runtime.manifest.ManifestParser;
private static <A> Manifest<A> manifest(final String strRep) {
return (Manifest<A>) ManifestParser.parse(strRep).right().get();
}
Manifest<Integer> refInfoInt =
manifest(Integer.class.getCanonicalName());
OptionAuditor<ModelIdentity, Integer> aud =
new OptionAuditor<>(refInfoInt);
// Using the diamond operator and extracting to a local variable
// in an IDE would produce the type on the LHS automatically.
ConstantModel<Object, Integer, Option<Integer>> constModel =
new ConstantModel<>(ModelId.empty(), 1, aud);
// Downcasting works without issue because of the type signatures.
Model<Object, Option<Integer>> model = constModel;
private static Model<Object, Option<Float>> getModel(
final Float constant
) throws JavaModelFactoryException {
Manifest<Float> refInfo = manifest("java.lang.Float");
Manifest<Object> refInfoObj = manifest("java.lang.Object");
OptionAuditor<ModelIdentity, Float> aud =
new OptionAuditor<ModelIdentity, Float>(refInfo);
JavaModelFactory factory =
new JavaModelFactory(new StdModelFactory());
Semantics<Object> semantics =
new Semantics<Object>(refInfoObj);
ModelId modelId = new ModelId(1, "test");
return factory.createConstantModel(semantics, aud, modelId, constant);
}
As demonstrated, this works nicely from Java but you'll notice that the since
there is a direct relationship between the N
and B
type parameters, the
B
parameter is redundant (at least in Scala).
The constant model instantiation works just fine, but when trying to create a model that takes submodels as parameters, things become much more hairy. That's because the exact same type constructor is required in the model and the submodels inside the model. This makes path-dependent types a natural fit but that makes model creation from Scala more difficult. This means that a models necessarily look like:
trait ModelWithSubmodel[-A, SN, N, +B] {
val auditor: Auditor[ModelIdentity, N, B]
def sub: Model[A, auditor.AuditOutput[SN]]
}
where SN
is the submodel's "natural output type". Notice that the
submodel's output type depends on the auditor
value. The downside of
this is that we can't just pass a submodel to case class constructor. We have
to have a factory method with two parameter lists that uses
type refinements
to construct its instances. This is required because auditor
needs to have a
singleton type in the refined type. That's all just so that we can ensure the submodel has the same type constructor as the auditor
.
Concretely, this looks like (which is pretty ugly and unwieldy):
abstract class HierarchicalConstantModel[-A, SN, N, +B](
override val modelId: ModelIdentity,
constant: N
) extends AuditedModel[A, N, B] {
val auditor: Auditor[ModelIdentity, N, B]
def sub: Model[A, auditor.AuditOutput[SN]]
def apply(a: A): B = auditor.success(modelId, constant, subValues = Seq(sub(a)))
}
object HierarchicalConstantModel {
def apply[A, SN, N, B](
mId: ModelIdentity,
v: N,
aud: Auditor[ModelIdentity, N, B])(
s: Model[A, aud.AuditOutput[SN]] // to refer to aud, must be in list #2
): HierarchicalConstantModel[A, SN, N, B] = {
new HierarchicalConstantModel[A, SN, N, B](mId, v) {
val auditor: aud.type = aud
val sub: Model[A, auditor.AuditOutput[SN]] = s
}
}
}
Wrapped type constructors, models w/ type constructors in output type.
In this method, the type constructor AuditOutput
is removed from Auditor
and is in its own type: TypeCtor
. TypeCtor
has a self type of Singleton
which means that only object
s can extend TypeCtor
. In this method
Auditor
, Model
implementations, and factories take a type parameter T
that is a subtype of TypeCtor
. TypeCtor
also implements the
Aux pattern from Shapeless.
This looks like:
trait TypeCtor { self: Singleton =>
type TC[+A]
def refInfo[A: RefInfo]: RefInfo[TC[A]]
}
object TypeCtor {
type Aux[C[+_]] = TypeCtor { type TC[A] = C[A] }
}
An example of a type constructor for Option
s looks like:
object OptionTC extends TypeCtor {
type TC[+A] = Option[A]
def refInfo[A: RefInfo] = RefInfo[Option[A]]
def instance: this.type = this // added for easy of use in Java
}
A constant model looks like:
case class ConstantModel[T <: TypeCtor, -A, N](
modelId: ModelIdentity,
constant: N,
tc: T,
auditor: Auditor[ModelIdentity, T, N]) extends Model[A, T#TC[N]] {
def apply(a: A): T#TC[N] = auditor.success(tc, modelId, constant)
}
Notice that there's no B
type parameter and that the output type of the model
is T#TC[N]
. Here we're using
type projection
rather than path dependent types as in Method 1. This doesn't seem like a big
gain but when write model implementations with submodels, it bears fruit, for
instance:
case class HierarchicalConstantModel[T <: TypeCtor, -A, +SN, N](
modelId: ModelIdentity,
constant: N,
sub: Model[A, T#TC[SN]],
tc: T,
auditor: Auditor[ModelIdentity, T, N]
) extends Model[A, T#TC[N]] {
def apply(a: A): T#TC[N] =
auditor.success(tc, modelId, constant, subValues = Seq(sa))
}
object HierarchicalConstantModel {
def apply[T <: TypeCtor, A, SN, N](
modelId: ModelIdentity,
constant: N,
tc: T,
auditor: Auditor[ModelIdentity, T, N])(
sub: Model[A, T#TC[SN]]
): HierarchicalConstantModel[T, A, SN, N] = {
new HierarchicalConstantModel[T, A, SN, N](modelId, constant, sub, tc, auditor)
}
}
val cm = ConstantModel(ModelId(), 2f, OptionTC, OptionAuditor[Float])
val hcm = HierarchicalConstantModel(ModelId(), 1, OptionTC, OptionAuditor[Int])(cm)
require(Option(1) == hcm(None))
See com.eharmony.aloha.score.audit.take2.JavaFactoryTest.test()
for details.
Unfortunately, it requires reflection via RefInfo
to coerce the model output
types.
A hybrid of Method 1 and 2: Use extraneous type parameters and TypeCtor
and
parameterize models, auditors, and factory by TypeCtor
type.