vmui/vmanomaly: add support models that produce only anomaly_score (#5594)

* vmui/vmanomaly: add support models that produce only `anomaly_score`

* vmui/vmanomaly: fix display legend

* vmui/vmanomaly: update comment on anomaly threshold
This commit is contained in:
Yury Molodov 2024-01-16 17:50:19 +01:00 committed by GitHub
parent 846d5a3ab8
commit d365157381
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 48 additions and 30 deletions

View file

@ -7,7 +7,7 @@ type Props = {
series: SeriesItem[];
};
const titles: Record<ForecastType, string> = {
const titles: Partial<Record<ForecastType, string>> = {
[ForecastType.yhat]: "yhat",
[ForecastType.yhatLower]: "yhat_lower/_upper",
[ForecastType.yhatUpper]: "yhat_lower/_upper",
@ -39,7 +39,6 @@ const LegendAnomaly: FC<Props> = ({ series }) => {
return uniqSeries.map(s => ({
...s,
color: typeof s.stroke === "string" ? s.stroke : anomalyColors[s.forecast || ForecastType.actual],
forecast: titles[s.forecast || ForecastType.actual],
}));
}, [series]);
@ -49,7 +48,7 @@ const LegendAnomaly: FC<Props> = ({ series }) => {
return <>
<div className="vm-legend-anomaly">
{/* TODO: remove .filter() after the correct training data has been added */}
{uniqSeriesStyles.filter(f => f.forecast !== titles[ForecastType.training]).map((s, i) => (
{uniqSeriesStyles.filter(f => f.forecast !== ForecastType.training).map((s, i) => (
<div
key={`${i}_${s.forecast}`}
className="vm-legend-anomaly-item"
@ -76,7 +75,7 @@ const LegendAnomaly: FC<Props> = ({ series }) => {
/>
)}
</svg>
<div className="vm-legend-anomaly-item__title">{s.forecast || "y"}</div>
<div className="vm-legend-anomaly-item__title">{titles[s.forecast || ForecastType.actual]}</div>
</div>
))}
</div>

View file

@ -5,7 +5,7 @@ import useEventListener from "../../hooks/useEventListener";
import "../CustomPanel/style.scss";
import ExploreAnomalyHeader from "./ExploreAnomalyHeader/ExploreAnomalyHeader";
import Alert from "../../components/Main/Alert/Alert";
import { extractFields } from "../../utils/uplot";
import { extractFields, isForecast } from "../../utils/uplot";
import { useFetchQuery } from "../../hooks/useFetchQuery";
import Spinner from "../../components/Main/Spinner/Spinner";
import GraphTab from "../CustomPanel/CustomPanelTabs/GraphTab";
@ -17,6 +17,16 @@ import { useFetchAnomalySeries } from "./hooks/useFetchAnomalySeries";
import { useQueryDispatch } from "../../state/query/QueryStateContext";
import { useTimeDispatch } from "../../state/time/TimeStateContext";
const anomalySeries = [
ForecastType.yhat,
ForecastType.yhatUpper,
ForecastType.yhatLower,
ForecastType.anomalyScore
];
// Hardcoded to 1.0 for now; consider adding a UI slider for threshold adjustment in the future.
const ANOMALY_SCORE_THRESHOLD = 1;
const ExploreAnomaly: FC = () => {
const { isMobile } = useDeviceDetect();
@ -36,34 +46,31 @@ const ExploreAnomaly: FC = () => {
const data = useMemo(() => {
if (!graphData) return;
const group = queries.length + 1;
const realData = graphData.filter(d => d.group === 1);
const upperData = graphData.filter(d => d.group === 3);
const lowerData = graphData.filter(d => d.group === 4);
const anomalyData: MetricResult[] = realData.map((d) => ({
group,
metric: { ...d.metric, __name__: ForecastType.anomaly },
values: d.values.filter(([t, v]) => {
const id = extractFields(d.metric);
const upperDataByLabels = upperData.find(du => extractFields(du.metric) === id);
const lowerDataByLabels = lowerData.find(du => extractFields(du.metric) === id);
if (!upperDataByLabels || !lowerDataByLabels) return false;
const max = upperDataByLabels.values.find(([tMax]) => tMax === t) as [number, string];
const min = lowerDataByLabels.values.find(([tMin]) => tMin === t) as [number, string];
const num = v && promValueToNumber(v);
const numMin = min && promValueToNumber(min[1]);
const numMax = max && promValueToNumber(max[1]);
return num < numMin || num > numMax;
})
}));
return graphData.concat(anomalyData);
const detectedData = graphData.map(d => ({ ...isForecast(d.metric), ...d }));
const realData = detectedData.filter(d => d.value === null);
const anomalyScoreData = detectedData.filter(d => d.isAnomalyScore);
const anomalyData: MetricResult[] = realData.map((d) => {
const id = extractFields(d.metric);
const anomalyScoreDataByLabels = anomalyScoreData.find(du => extractFields(du.metric) === id);
return {
group: queries.length + 1,
metric: { ...d.metric, __name__: ForecastType.anomaly },
values: d.values.filter(([t]) => {
if (!anomalyScoreDataByLabels) return false;
const anomalyScore = anomalyScoreDataByLabels.values.find(([tMax]) => tMax === t) as [number, string];
return anomalyScore && promValueToNumber(anomalyScore[1]) > ANOMALY_SCORE_THRESHOLD;
})
};
});
return graphData.filter(d => d.group !== anomalyScoreData[0]?.group).concat(anomalyData);
}, [graphData]);
const onChangeFilter = (expr: Record<string, string>) => {
const { __name__ = "", ...labelValue } = expr;
let prefix = __name__.replace(/y|_y/, "");
if (prefix) prefix += "_";
const metrics = [__name__, ForecastType.yhat, ForecastType.yhatUpper, ForecastType.yhatLower];
const metrics = [__name__, ...anomalySeries];
const filters = Object.entries(labelValue).map(([key, value]) => `${key}="${value}"`).join(",");
const queries = metrics.map((m, i) => `${i ? prefix : ""}${m}{${filters}}`);
queryDispatch({ type: "SET_QUERY", payload: queries });

View file

@ -3,6 +3,8 @@ import { useAppState } from "../../../state/common/StateContext";
import { ErrorTypes } from "../../../types";
import { useEffect } from "react";
import { MetricBase } from "../../../api/types";
import { useTimeState } from "../../../state/time/TimeStateContext";
import dayjs from "dayjs";
// TODO: Change the method of retrieving aliases from the configuration after the API has been added
const seriesQuery = `{
@ -12,18 +14,25 @@ const seriesQuery = `{
export const useFetchAnomalySeries = () => {
const { serverUrl } = useAppState();
const { period: { start, end } } = useTimeState();
const [series, setSeries] = useState<Record<string, MetricBase["metric"][]>>();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>();
// TODO add cached metrics by date
const fetchUrl = useMemo(() => {
const startDay = dayjs(start * 1000).startOf("day").valueOf() / 1000;
const endDay = dayjs(end * 1000).endOf("day").valueOf() / 1000;
const params = new URLSearchParams({
"match[]": seriesQuery,
start: `${startDay}`,
end: `${endDay}`
});
return `${serverUrl}/api/v1/series?${params}`;
}, [serverUrl]);
}, [serverUrl, start, end]);
useEffect(() => {
const fetchSeries = async () => {

View file

@ -6,7 +6,8 @@ export enum ForecastType {
yhatLower = "yhat_lower",
anomaly = "vmui_anomalies_points",
training = "vmui_training_data",
actual = "actual"
actual = "actual",
anomalyScore = "anomaly_score",
}
export interface SeriesItemStatsFormatted {

View file

@ -26,6 +26,7 @@ export const anomalyColors: Record<ForecastType, string> = {
[ForecastType.yhatLower]: "#7126a1",
[ForecastType.yhat]: "#da42a6",
[ForecastType.anomaly]: "#da4242",
[ForecastType.anomalyScore]: "#7126a1",
[ForecastType.actual]: "#203ea9",
[ForecastType.training]: `rgba(${hexToRGB("#203ea9")}, 0.2)`,
};

View file

@ -14,7 +14,7 @@ export const extractFields = (metric: MetricBase["metric"]): string => {
.map(([key, value]) => `${key}: ${value}`).join(",");
};
const isForecast = (metric: MetricBase["metric"]) => {
export const isForecast = (metric: MetricBase["metric"]) => {
const metricName = metric?.__name__ || "";
const forecastRegex = new RegExp(`(${Object.values(ForecastType).join("|")})$`);
const match = metricName.match(forecastRegex);
@ -25,6 +25,7 @@ const isForecast = (metric: MetricBase["metric"]) => {
isLower: value === ForecastType.yhatLower,
isYhat: value === ForecastType.yhat,
isAnomaly: value === ForecastType.anomaly,
isAnomalyScore: value === ForecastType.anomalyScore,
group: extractFields(metric)
};
};