Merge pull request #601 from moririn2528/preferential-graph

Add Preference Graph
This commit is contained in:
c-bata
2023-09-11 17:14:58 +09:00
committed by GitHub
11 changed files with 1832 additions and 73 deletions
+2
View File
@@ -18,6 +18,7 @@ from ._named_objectives import get_objective_names
from ._preferential_history import _SYSTEM_ATTR_PREFIX_HISTORY
from .artifact._backend import list_trial_artifacts
from .preferential._study import _SYSTEM_ATTR_PREFERENTIAL_STUDY
from .preferential._system_attrs import get_preferences
if TYPE_CHECKING:
@@ -163,6 +164,7 @@ def serialize_study_detail(
serialized["form_widgets"] = form_widgets
if serialized["is_preferential"]:
serialized["preference_history"] = serialize_preference_history(system_attrs)
serialized["preferences"] = get_preferences(system_attrs)
serialized["plotly_graph_objects"] = [
{"id": id_, "graph_object": graph_object}
for id_, graph_object in plotly_graph_objects.items()
+2
View File
@@ -92,6 +92,7 @@ interface StudyDetailResponse {
is_preferential: boolean
objective_names?: string[]
form_widgets?: FormWidgets
preferences?: [number, number][]
preference_history?: PreferenceHistoryResponce[]
plotly_graph_objects: PlotlyGraphObject[]
}
@@ -129,6 +130,7 @@ export const getStudyDetailAPI = (
objective_names: res.data.objective_names,
form_widgets: res.data.form_widgets,
is_preferential: res.data.is_preferential,
preferences: res.data.preferences,
preference_history: res.data.preference_history?.map(
convertPreferenceHistory
),
+9
View File
@@ -87,6 +87,15 @@ export const App: FC = () => {
/>
}
/>
<Route
path={URL_PREFIX + "/studies/:studyId/graph"}
element={
<StudyDetail
toggleColorMode={toggleColorMode}
page={"graph"}
/>
}
/>
<Route
path={URL_PREFIX + "/studies/:studyId"}
element={
+24 -9
View File
@@ -17,8 +17,7 @@ import ListItemText from "@mui/material/ListItemText"
import {
drawerOpenState,
reloadIntervalState,
useStudyDetailValue,
useStudySummaryValue,
useStudyIsPreferencial,
} from "../state"
import { Link } from "react-router-dom"
import AutoGraphIcon from "@mui/icons-material/AutoGraph"
@@ -35,6 +34,7 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew"
import QueryStatsIcon from "@mui/icons-material/QueryStats"
import ThumbUpAltIcon from "@mui/icons-material/ThumbUpAlt"
import HistoryIcon from "@mui/icons-material/History"
import LanIcon from "@mui/icons-material/Lan"
import { Switch } from "@mui/material"
import { actionCreator } from "../action"
@@ -47,6 +47,7 @@ export type PageId =
| "trialList"
| "note"
| "preferenceHistory"
| "graph"
const openedMixin = (theme: Theme): CSSObject => ({
width: drawerWidth,
@@ -128,12 +129,8 @@ export const AppDrawer: FC<{
const action = actionCreator()
const [open, setOpen] = useRecoilState<boolean>(drawerOpenState)
const reloadInterval = useRecoilValue<number>(reloadIntervalState)
const studyDetail =
studyId !== undefined ? useStudyDetailValue(studyId) : null
const studySummary =
studyId !== undefined ? useStudySummaryValue(studyId) : null
const isPreferential =
studyDetail?.is_preferential ?? studySummary?.is_preferential ?? false
studyId !== undefined ? useStudyIsPreferencial(studyId) : null
const styleListItem = {
display: "block",
@@ -206,7 +203,7 @@ export const AppDrawer: FC<{
{isPreferential ? <ThumbUpAltIcon /> : <AutoGraphIcon />}
</ListItemIcon>
<ListItemText
primary={isPreferential ? "HumanInTheLoop" : "History"}
primary={isPreferential ? "Feedback Preference" : "History"}
sx={styleListItemText}
/>
</ListItemButton>
@@ -227,7 +224,7 @@ export const AppDrawer: FC<{
<HistoryIcon />
</ListItemIcon>
<ListItemText
primary="PreferenceHistory"
primary="Preferences (History)"
sx={styleListItemText}
/>
</ListItemButton>
@@ -246,6 +243,24 @@ export const AppDrawer: FC<{
<ListItemText primary="Analytics" sx={styleListItemText} />
</ListItemButton>
</ListItem>
{isPreferential && (
<ListItem key="PreferenceGraph" disablePadding sx={styleListItem}>
<ListItemButton
component={Link}
to={`${URL_PREFIX}/studies/${studyId}/graph`}
sx={styleListItemButton}
selected={page === "graph"}
>
<ListItemIcon sx={styleListItemIcon}>
<LanIcon />
</ListItemIcon>
<ListItemText
primary="Preferences (Graph)"
sx={styleListItemText}
/>
</ListItemButton>
</ListItem>
)}
<ListItem key="TableList" disablePadding sx={styleListItem}>
<ListItemButton
component={Link}
@@ -0,0 +1,273 @@
import React, { FC, useState, useCallback, useMemo, useEffect } from "react"
import {
Card,
CardContent,
useTheme,
Typography,
Box,
Chip,
} from "@mui/material"
import { MarkdownRenderer } from "./Note"
import ReactFlow, {
Node,
NodeProps,
NodeTypes,
Edge,
DefaultEdgeOptions,
applyNodeChanges,
OnNodesChange,
MiniMap,
Position,
Handle,
} from "reactflow"
import "reactflow/dist/style.css"
import ELK from "elkjs/lib/elk.bundled.js"
import { ElkNode } from "elkjs/lib/elk-api.js"
const elk = new ELK()
const nodeWidth = 400
const nodeHeight = 300
const nodeMargin = 60
type NodeData = {
trial?: Trial
isBest: boolean
}
const GraphNode: FC<NodeProps<NodeData>> = ({ data, isConnectable }) => {
const theme = useTheme()
const trial = data.trial
if (trial === undefined) {
return null
}
const noteBody = trial.note.body
const noteFC = useMemo(() => {
return <MarkdownRenderer body={noteBody} />
}, [noteBody])
return (
<Card
sx={{
width: nodeWidth,
height: nodeHeight,
overflow: "hidden",
}}
>
<Box
sx={{
display: "flex",
displayDirection: "row",
margin: theme.spacing(2),
}}
>
<Typography variant="h5">Trial {trial.number}</Typography>
{data.isBest && (
<Chip
label={"Best Trial"}
color="secondary"
variant="outlined"
sx={{
marginLeft: "auto",
}}
/>
)}
</Box>
<Handle
type="target"
position={Position.Top}
style={{ background: "#555" }}
isConnectable={isConnectable}
/>
<CardContent>{noteFC}</CardContent>
<Handle
type="source"
position={Position.Bottom}
style={{ background: "#555" }}
isConnectable={isConnectable}
/>
</Card>
)
}
const nodeTypes: NodeTypes = {
note: GraphNode,
}
const defaultEdgeOptions: DefaultEdgeOptions = {
animated: true,
}
const reductionPreference = (
input_preferences: [number, number][]
): [number, number][] => {
const preferences: [number, number][] = []
let n = 0
for (const [source, target] of input_preferences) {
if (
preferences.find((p) => p[0] === source && p[1] === target) !==
undefined ||
input_preferences.find((p) => p[0] === target && p[1] === source) !==
undefined
) {
continue
}
n = Math.max(n - 1, source, target) + 1
preferences.push([source, target])
}
if (n === 0) {
return []
}
const graph: number[][] = Array.from({ length: n }, () => [])
const reverseGraph: number[][] = Array.from({ length: n }, () => [])
const degree: number[] = Array.from({ length: n }, () => 0)
for (const [source, target] of preferences) {
graph[source].push(target)
reverseGraph[target].push(source)
degree[target]++
}
const topologicalOrder: number[] = []
const q: number[] = []
for (let i = 0; i < n; i++) {
if (degree[i] === 0) {
q.push(i)
}
}
while (q.length > 0) {
const v = q.pop()
if (v === undefined) break
topologicalOrder.push(v)
graph[v].forEach((u) => {
degree[u]--
if (degree[u] === 0) {
q.push(u)
}
})
}
if (topologicalOrder.length !== n) {
console.error("cycle detected")
return preferences
}
const response: [number, number][] = []
const descendants: Set<number>[] = Array.from(
{ length: n },
() => new Set<number>()
)
topologicalOrder.reverse().forEach((v) => {
const descendant = new Set<number>([v])
graph[v].forEach((u) => {
descendants[u].forEach((d) => descendant.add(d))
})
graph[v].forEach((u) => {
if (reverseGraph[u].filter((d) => descendant.has(d)).length === 1) {
response.push([v, u])
}
})
descendants[v] = descendant
})
return response
}
export const PreferentialGraph: FC<{
studyDetail: StudyDetail | null
}> = ({ studyDetail }) => {
const theme = useTheme()
const [nodes, setNodes] = useState<Node[]>([])
const [edges, setEdges] = useState<Edge[]>([])
const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[setNodes]
)
useEffect(() => {
if (studyDetail === null) return
if (!studyDetail.is_preferential || studyDetail.preferences === undefined)
return
const preferences = reductionPreference(studyDetail.preferences)
const graph: ElkNode = {
id: "root",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "DOWN",
"elk.layered.spacing.nodeNodeBetweenLayers": nodeMargin.toString(),
"elk.spacing.nodeNode": nodeMargin.toString(),
},
children: studyDetail.trials.map((trial) => ({
id: `${trial.number}`,
targetPosition: "top",
sourcePosition: "bottom",
width: nodeWidth,
height: nodeHeight,
})),
edges: preferences.map(([source, target]) => ({
id: `e${source}-${target}`,
sources: [`${source}`],
targets: [`${target}`],
})),
}
elk
.layout(graph)
.then((layoutedGraph) => {
setNodes(
layoutedGraph.children?.map((node, index) => {
const trial = studyDetail.trials[index]
return {
id: `${trial.number}`,
type: "note",
data: {
label: `Trial ${trial.number}`,
trial: trial,
isBest:
studyDetail.best_trials.find(
(t) => t.number === trial.number
) !== undefined,
},
position: {
x: node.x ?? 0,
y: node.y ?? 0,
},
style: {
width: nodeWidth,
height: nodeHeight,
padding: 0,
},
deletable: false,
connectable: false,
draggable: false,
}
}) ?? []
)
})
.catch(console.error)
setEdges(
preferences.map((p) => {
return {
id: `e${p[0]}-${p[1]}`,
source: `${p[0]}`,
target: `${p[1]}`,
style: { stroke: theme.palette.text.primary },
} as Edge
}) ?? []
)
}, [studyDetail, theme.palette.text.primary])
if (studyDetail === null || !studyDetail.is_preferential) {
return null
}
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
defaultEdgeOptions={defaultEdgeOptions}
nodeTypes={nodeTypes}
zoomOnScroll={false}
panOnScroll={true}
minZoom={0.1}
defaultViewport={{
x: 0,
y: 0,
zoom: 0.5,
}}
>
<MiniMap nodeStrokeWidth={1} zoomable pannable />
</ReactFlow>
)
}
+14 -4
View File
@@ -18,8 +18,8 @@ import { actionCreator } from "../action"
import {
reloadIntervalState,
useStudyDetailValue,
useStudyIsPreferencial,
useStudyName,
useStudySummaryValue,
} from "../state"
import { TrialTable } from "./TrialTable"
import { AppDrawer, PageId } from "./AppDrawer"
@@ -32,6 +32,7 @@ import { StudyHistory } from "./StudyHistory"
import { PreferentialTrials } from "./PreferentialTrials"
import { PreferenceHistory } from "./PreferenceHistory"
import { PreferentialAnalytics } from "./PreferentialAnalytics"
import { PreferentialGraph } from "./PreferentialGraph"
interface ParamTypes {
studyId: string
@@ -51,11 +52,9 @@ export const StudyDetail: FC<{
const action = actionCreator()
const studyId = useURLVars()
const studyDetail = useStudyDetailValue(studyId)
const studySummary = useStudySummaryValue(studyId)
const reloadInterval = useRecoilValue<number>(reloadIntervalState)
const studyName = useStudyName(studyId)
const isPreferential =
studySummary?.is_preferential ?? studyDetail?.is_preferential ?? false
const isPreferential = useStudyIsPreferencial(studyId)
const title =
studyName !== null ? `${studyName} (id=${studyId})` : `Study #${studyId}`
@@ -176,6 +175,17 @@ export const StudyDetail: FC<{
/>
</Box>
)
} else if (page === "graph") {
content = (
<Box
sx={{
height: `calc(100vh - ${theme.spacing(8)})`,
padding: theme.spacing(2),
}}
>
<PreferentialGraph studyDetail={studyDetail} />
</Box>
)
} else if (page == "preferenceHistory") {
content = <PreferenceHistory studyDetail={studyDetail} />
}
+6
View File
@@ -87,6 +87,12 @@ export const useStudyDirections = (
return studyDetail?.directions || studySummary?.directions || null
}
export const useStudyIsPreferencial = (studyId: number): boolean | null => {
const studyDetail = useStudyDetailValue(studyId)
const studySummary = useStudySummaryValue(studyId)
return studyDetail?.is_preferential || studySummary?.is_preferential || null
}
export const useStudyName = (studyId: number): string | null => {
const studyDetail = useStudyDetailValue(studyId)
const studySummary = useStudySummaryValue(studyId)
+1 -1
View File
@@ -203,6 +203,7 @@ type StudyDetail = {
is_preferential: boolean
objective_names?: string[]
form_widgets?: FormWidgets
preferences?: [number, number][]
preference_history?: PreferenceHistory[]
plotly_graph_objects: PlotlyGraphObject[]
}
@@ -214,7 +215,6 @@ type StudyDetails = {
type StudyParamImportance = {
[study_id: string]: ParamImportance[][]
}
type PreferenceHistory = {
id: string
preference_id: string
+1422
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -27,6 +27,7 @@
"@react-three/fiber": "^8.13.6",
"@types/three": "^0.154.0",
"axios": "^1.2.1",
"elkjs": "^0.8.2",
"notistack": "^3.0.1",
"plotly.js-dist-min": "^2.22.0",
"react": "^18.2.0",
@@ -34,6 +35,7 @@
"react-markdown": "^8.0.4",
"react-router-dom": "^6.11.0",
"react-syntax-highlighter": "^15.5.0",
"reactflow": "^11.8.3",
"recoil": "^0.7.7",
"rehype-mathjax": "^4.0.2",
"rehype-raw": "^6.1.1",
@@ -53,12 +55,14 @@
"@typescript-eslint/eslint-plugin": "^4.26.1",
"@typescript-eslint/parser": "^4.26.1",
"compression-webpack-plugin": "^10.0.0",
"css-loader": "^6.8.1",
"esbuild-loader": "^2.18.0",
"eslint": "^7.28.0",
"jest": "^29.2.1",
"jest-canvas-mock": "^2.3.1",
"jest-environment-jsdom": "^29.3.1",
"prettier": "^2.5.1",
"style-loader": "^3.3.3",
"ts-jest": "^29.0.3",
"ts-loader": "^9.2.7",
"typescript": "^4.6.2",
+75 -59
View File
@@ -1,65 +1,81 @@
const webpack = require('webpack');
const webpack = require("webpack")
const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
const isDev = mode === 'development';
const mode =
process.env.NODE_ENV === "production" ? "production" : "development"
const isDev = mode === "development"
const typeScriptLoader = process.env.TYPESCRIPT_LOADER === "esbuild-loader" ? {
test: /\.tsx?$/,
exclude: [/node_modules/],
loader: 'esbuild-loader',
options: {
loader: 'tsx',
tsconfigRaw: require('./tsconfig.json')
}
} : {
test: /\.tsx?$/,
exclude: [/node_modules/],
loader: 'ts-loader',
options: {
configFile: __dirname + '/tsconfig.json',
transpileOnly: isDev,
happyPackMode: true
}
}
const typeScriptLoader =
process.env.TYPESCRIPT_LOADER === "esbuild-loader"
? {
test: /\.tsx?$/,
exclude: [/node_modules/],
loader: "esbuild-loader",
options: {
loader: "tsx",
tsconfigRaw: require("./tsconfig.json"),
},
}
: {
test: /\.tsx?$/,
exclude: [/node_modules/],
loader: "ts-loader",
options: {
configFile: __dirname + "/tsconfig.json",
transpileOnly: isDev,
happyPackMode: true,
},
}
var config = {
mode,
entry: [__dirname + '/optuna_dashboard/ts/index.tsx'],
output: {
path: __dirname + '/optuna_dashboard/public/',
filename: 'bundle.js',
publicPath: '/public/'
},
module: {
rules: [{oneOf: [typeScriptLoader]}]
},
resolve: {
extensions: ['.ts', '.tsx', '.js']
},
plugins: [
new webpack.DefinePlugin({
'APP_BAR_TITLE': JSON.stringify(process.env.APP_BAR_TITLE || "Optuna Dashboard"),
'API_ENDPOINT': JSON.stringify(process.env.API_ENDPOINT),
'URL_PREFIX': JSON.stringify(process.env.URL_PREFIX || "/dashboard")
})
]
};
if (isDev) {
config.devtool = 'source-map';
config.cache = {
type: 'filesystem',
buildDependencies: {
config: [__filename],
}
}
console.log('= = = = = = = = = = = = = = = = = = =');
console.log('DEVELOPMENT BUILD');
console.log(process.env.TYPESCRIPT_LOADER === 'esbuild-loader' ? 'esbuild-loader' : 'ts-loader');
console.log('= = = = = = = = = = = = = = = = = = =');
} else {
const CompressionPlugin = require("compression-webpack-plugin");
config.plugins.push(new CompressionPlugin())
mode,
entry: [__dirname + "/optuna_dashboard/ts/index.tsx"],
output: {
path: __dirname + "/optuna_dashboard/public/",
filename: "bundle.js",
publicPath: "/public/",
},
module: {
rules: [
{ oneOf: [typeScriptLoader] },
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
],
},
resolve: {
extensions: [".ts", ".tsx", ".js"],
},
plugins: [
new webpack.DefinePlugin({
APP_BAR_TITLE: JSON.stringify(
process.env.APP_BAR_TITLE || "Optuna Dashboard"
),
API_ENDPOINT: JSON.stringify(process.env.API_ENDPOINT),
URL_PREFIX: JSON.stringify(process.env.URL_PREFIX || "/dashboard"),
}),
],
}
module.exports = config;
if (isDev) {
config.devtool = "source-map"
config.cache = {
type: "filesystem",
buildDependencies: {
config: [__filename],
},
}
console.log("= = = = = = = = = = = = = = = = = = =")
console.log("DEVELOPMENT BUILD")
console.log(
process.env.TYPESCRIPT_LOADER === "esbuild-loader"
? "esbuild-loader"
: "ts-loader"
)
console.log("= = = = = = = = = = = = = = = = = = =")
} else {
const CompressionPlugin = require("compression-webpack-plugin")
config.plugins.push(new CompressionPlugin())
}
module.exports = config